"use node"; import { v } from "convex/values"; import { action } from "./_generated/server"; import { shouldLogVideoPollResult, type VideoPollStatus } from "../lib/video-poll-logging"; const FREEPIK_BASE = "https://api.freepik.com"; const FREEPIK_REQUEST_TIMEOUT_MS = 30_000; const FREEPIK_MAX_RETRIES = 2; export type FreepikVideoTaskStatus = | "CREATED" | "IN_PROGRESS" | "COMPLETED" | "FAILED"; export interface FreepikVideoTaskStatusResponse { status: FreepikVideoTaskStatus; generated?: Array<{ url: string }>; error?: string; } export interface FreepikMappedError { code: "model_unavailable" | "timeout" | "transient" | "unknown"; message: string; retryable: boolean; } export class FreepikApiError extends Error { readonly source = "freepik" as const; readonly status?: number; readonly code: FreepikMappedError["code"]; readonly retryable: boolean; readonly body?: unknown; constructor(args: { status?: number; code: FreepikMappedError["code"]; message: string; retryable: boolean; body?: unknown; }) { super(args.message); this.name = "FreepikApiError"; this.status = args.status; this.code = args.code; this.retryable = args.retryable; this.body = args.body; } } type AssetType = "photo" | "vector" | "icon"; interface FreepikResult { id: number; title: string; assetType: AssetType; previewUrl: string; intrinsicWidth?: number; intrinsicHeight?: number; sourceUrl: string; license: "freemium" | "premium"; authorName: string; orientation?: string; } interface FreepikSearchResponse { results: FreepikResult[]; totalPages: number; currentPage: number; total: number; } function parseSize(size?: string): { width?: number; height?: number } { if (!size) return {}; const match = size.match(/^(\d+)x(\d+)$/i); if (!match) return {}; const width = Number(match[1]); const height = Number(match[2]); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { return {}; } return { width, height }; } function wait(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function getFreepikApiKeyOrThrow(): string { const apiKey = process.env.FREEPIK_API_KEY; if (!apiKey) { throw new FreepikApiError({ code: "model_unavailable", message: "FREEPIK_API_KEY not set", retryable: false, }); } return apiKey; } function normalizeFreepikEndpoint(path: string): string { if (path.startsWith("http://") || path.startsWith("https://")) { return path; } if (path.startsWith("/")) { return `${FREEPIK_BASE}${path}`; } return `${FREEPIK_BASE}/${path}`; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } function extractErrorDetail(body: unknown): string | undefined { if (typeof body === "string" && body.trim().length > 0) { return body.trim(); } if (!isRecord(body)) { return undefined; } const direct = typeof body.error === "string" ? body.error : typeof body.message === "string" ? body.message : undefined; if (direct && direct.trim().length > 0) { return direct.trim(); } const data = body.data; if (isRecord(data)) { const nested = typeof data.error === "string" ? data.error : typeof data.message === "string" ? data.message : undefined; if (nested && nested.trim().length > 0) { return nested.trim(); } } return undefined; } export function mapFreepikError(status: number, body: unknown): FreepikMappedError { const detail = extractErrorDetail(body); if (status === 401) { return { code: "model_unavailable", message: "Freepik API-Key ungueltig", retryable: false, }; } if (status === 400) { return { code: "unknown", message: detail ?? "Ungueltige Parameter fuer dieses Modell", retryable: false, }; } if (status === 404) { return { code: "transient", message: detail ?? "Freepik Task noch nicht verfuegbar", retryable: true, }; } if (status === 503) { return { code: "model_unavailable", message: "Freepik temporaer nicht verfuegbar", retryable: true, }; } if (status === 408 || status === 504) { return { code: "timeout", message: detail ?? "Freepik timeout", retryable: true, }; } if (status === 429) { return { code: "transient", message: detail ?? "Freepik Rate-Limit erreicht", retryable: true, }; } if (status >= 500) { return { code: "transient", message: detail ?? "Freepik Serverfehler", retryable: true, }; } return { code: "unknown", message: detail ?? "Unbekannter Freepik-Fehler", retryable: false, }; } function isNetworkLikeError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } const lower = error.message.toLowerCase(); return ( lower.includes("fetch failed") || lower.includes("network") || lower.includes("connection") || lower.includes("econn") ); } async function parseResponseBody(response: Response): Promise { const text = await response.text(); if (!text) { return undefined; } try { return JSON.parse(text); } catch { return text; } } async function freepikJsonRequest(params: { path: string; method: "GET" | "POST"; body?: string; useApiKey?: boolean; }): Promise { const apiKey = params.useApiKey === false ? null : getFreepikApiKeyOrThrow(); const url = normalizeFreepikEndpoint(params.path); for (let attempt = 0; attempt <= FREEPIK_MAX_RETRIES; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FREEPIK_REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { method: params.method, headers: { Accept: "application/json", ...(apiKey ? { "x-freepik-api-key": apiKey } : {}), ...(params.body ? { "Content-Type": "application/json" } : {}), }, body: params.body, signal: controller.signal, }); if (!response.ok) { const responseBody = await parseResponseBody(response); const mapped = mapFreepikError(response.status, responseBody); const mappedError = new FreepikApiError({ status: response.status, code: mapped.code, message: mapped.message, retryable: mapped.retryable, body: responseBody, }); if (mapped.retryable && attempt < FREEPIK_MAX_RETRIES) { await wait(Math.min(1200, 300 * (attempt + 1))); continue; } throw mappedError; } return (await response.json()) as TResponse; } catch (error) { const isTimeout = error instanceof Error && error.name === "AbortError"; const retryable = isTimeout || isNetworkLikeError(error); if (retryable && attempt < FREEPIK_MAX_RETRIES) { await wait(Math.min(1200, 300 * (attempt + 1))); continue; } if (isTimeout) { throw new FreepikApiError({ code: "timeout", message: "Freepik timeout", retryable: true, }); } if (isNetworkLikeError(error)) { throw new FreepikApiError({ code: "transient", message: error instanceof Error ? error.message : "Netzwerkfehler bei Freepik", retryable: true, }); } throw error; } finally { clearTimeout(timeout); } } throw new FreepikApiError({ code: "unknown", message: "Freepik request failed", retryable: false, }); } function buildTaskStatusPath(statusEndpointPath: string, taskId: string): string { const trimmedTaskId = taskId.trim(); if (!trimmedTaskId) { throw new FreepikApiError({ code: "unknown", message: "Missing Freepik task_id for status polling", retryable: false, }); } if (statusEndpointPath.includes("{task-id}")) { return statusEndpointPath.replaceAll("{task-id}", encodeURIComponent(trimmedTaskId)); } const suffix = statusEndpointPath.endsWith("/") ? "" : "/"; return `${statusEndpointPath}${suffix}${encodeURIComponent(trimmedTaskId)}`; } export async function createVideoTask(params: { endpoint: string; prompt: string; durationSeconds: 5 | 10; webhookUrl?: string; imageUrl?: string; }): Promise<{ task_id: string }> { const payload: Record = { prompt: params.prompt, duration: params.durationSeconds, }; if (params.webhookUrl) { payload.webhook_url = params.webhookUrl; } if (params.imageUrl) { payload.image_url = params.imageUrl; } const result = await freepikJsonRequest<{ data?: { task_id?: string } }>({ path: params.endpoint, method: "POST", body: JSON.stringify(payload), }); console.info("[freepik.createVideoTask] response", { endpoint: params.endpoint, durationSeconds: params.durationSeconds, hasImageUrl: Boolean(params.imageUrl), promptLength: params.prompt.length, responseKeys: isRecord(result) ? Object.keys(result) : [], dataKeys: isRecord(result.data) ? Object.keys(result.data) : [], }); const taskId = typeof result.data?.task_id === "string" ? result.data.task_id : typeof (result as { task_id?: unknown }).task_id === "string" ? (result as { task_id: string }).task_id : undefined; if (typeof taskId !== "string" || taskId.trim().length === 0) { throw new FreepikApiError({ code: "unknown", message: "Freepik response missing task_id", retryable: false, body: result, }); } return { task_id: taskId }; } export async function getVideoTaskStatus(params: { taskId: string; statusEndpointPath: string; attempt?: number; }): Promise { const statusPath = buildTaskStatusPath(params.statusEndpointPath, params.taskId); const result = await freepikJsonRequest<{ data?: { status?: string; generated?: unknown; error?: string; message?: string; }; status?: string; generated?: unknown; error?: string; message?: string; }>({ path: statusPath, method: "GET", }); const statusRaw = typeof result.data?.status === "string" ? result.data.status : typeof result.status === "string" ? result.status : undefined; const status = statusRaw === "CREATED" || statusRaw === "IN_PROGRESS" || statusRaw === "COMPLETED" || statusRaw === "FAILED" ? statusRaw : null; if ( status && shouldLogVideoPollResult(params.attempt ?? 1, status as VideoPollStatus) ) { console.info("[freepik.getVideoTaskStatus] response", { taskId: params.taskId, statusPath, statusRaw: typeof statusRaw === "string" ? statusRaw : null, acceptedStatus: status, dataKeys: isRecord(result.data) ? Object.keys(result.data) : [], generatedCount: Array.isArray(result.data?.generated) ? result.data.generated.length : Array.isArray(result.generated) ? result.generated.length : 0, hasError: typeof result.data?.error === "string" || typeof result.error === "string", hasMessage: typeof result.data?.message === "string" || typeof result.message === "string", }); } if (!status) { console.warn("[freepik.getVideoTaskStatus] unexpected response", { taskId: params.taskId, statusPath, result, }); throw new FreepikApiError({ code: "unknown", message: "Freepik task status missing or invalid", retryable: false, body: result, }); } const generatedRaw = Array.isArray(result.data?.generated) ? result.data.generated : Array.isArray(result.generated) ? result.generated : undefined; const generated = Array.isArray(generatedRaw) ? generatedRaw .map((entry) => { const url = typeof entry === "string" ? entry : isRecord(entry) && typeof entry.url === "string" ? entry.url : undefined; if (!url) return null; return { url }; }) .filter((entry): entry is { url: string } => entry !== null) : undefined; const error = typeof result.data?.error === "string" ? result.data.error : typeof result.data?.message === "string" ? result.data.message : typeof result.error === "string" ? result.error : typeof result.message === "string" ? result.message : undefined; return { status, ...(generated && generated.length > 0 ? { generated } : {}), ...(error ? { error } : {}), }; } export async function downloadVideoAsBlob(url: string): Promise { for (let attempt = 0; attempt <= FREEPIK_MAX_RETRIES; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FREEPIK_REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { method: "GET", signal: controller.signal, }); if (!response.ok) { const body = await parseResponseBody(response); const mapped = mapFreepikError(response.status, body); const mappedError = new FreepikApiError({ status: response.status, code: mapped.code, message: mapped.message, retryable: mapped.retryable, body, }); if (mapped.retryable && attempt < FREEPIK_MAX_RETRIES) { await wait(Math.min(1200, 300 * (attempt + 1))); continue; } throw mappedError; } return await response.blob(); } catch (error) { const isTimeout = error instanceof Error && error.name === "AbortError"; const retryable = isTimeout || isNetworkLikeError(error); if (retryable && attempt < FREEPIK_MAX_RETRIES) { await wait(Math.min(1200, 300 * (attempt + 1))); continue; } if (isTimeout) { throw new FreepikApiError({ code: "timeout", message: "Freepik video download timeout", retryable: true, }); } if (error instanceof FreepikApiError) { throw error; } if (isNetworkLikeError(error)) { throw new FreepikApiError({ code: "transient", message: error instanceof Error ? error.message : "Netzwerkfehler beim Video-Download", retryable: true, }); } throw error; } finally { clearTimeout(timeout); } } throw new FreepikApiError({ code: "unknown", message: "Freepik video download failed", retryable: false, }); } export const search = action({ args: { term: v.string(), assetType: v.union(v.literal("photo"), v.literal("vector"), v.literal("icon")), page: v.optional(v.number()), limit: v.optional(v.number()), }, handler: async (_ctx, args): Promise => { const apiKey = process.env.FREEPIK_API_KEY; if (!apiKey) { throw new Error("FREEPIK_API_KEY not set"); } const page = args.page ?? 1; const limit = args.limit ?? 20; const params = new URLSearchParams({ term: args.term, page: String(page), order: "relevance", "filters[license][freemium]": "1", }); let endpoint = `${FREEPIK_BASE}/v1/resources`; if (args.assetType === "icon") { endpoint = `${FREEPIK_BASE}/v1/icons`; params.set("per_page", String(limit)); } else { params.set("limit", String(limit)); params.set(`filters[content_type][${args.assetType}]`, "1"); } const res = await fetch(`${endpoint}?${params.toString()}`, { headers: { "x-freepik-api-key": apiKey, Accept: "application/json", }, }); if (!res.ok) { throw new Error(`Freepik API error: ${res.status} ${res.statusText}`); } const json = (await res.json()) as { data?: Array<{ id?: number; title?: string; url?: string; image?: { orientation?: string; source?: { url?: string; size?: string; }; }; licenses?: Array<{ type?: string }>; author?: { name?: string }; }>; meta?: { total?: number; current_page?: number; last_page?: number; total_pages?: number; pagination?: { total?: number; current_page?: number; last_page?: number; total_pages?: number; }; }; }; const data = json.data ?? []; const pagination = json.meta?.pagination; const results = data .map((item): FreepikResult | null => { if (!item.id || !item.image?.source?.url || !item.url) { return null; } const license = item.licenses?.some((entry) => entry.type === "freemium") ? "freemium" : "premium"; const parsedSize = parseSize(item.image?.source?.size); return { id: item.id, title: item.title ?? "Untitled", assetType: args.assetType, previewUrl: item.image.source.url, intrinsicWidth: parsedSize.width, intrinsicHeight: parsedSize.height, sourceUrl: item.url, license, authorName: item.author?.name ?? "Freepik", orientation: item.image.orientation, }; }) .filter((entry): entry is FreepikResult => entry !== null); const totalPagesRaw = pagination?.last_page ?? pagination?.total_pages ?? json.meta?.last_page ?? json.meta?.total_pages ?? 1; const currentPageRaw = pagination?.current_page ?? json.meta?.current_page ?? page; const totalRaw = pagination?.total ?? json.meta?.total ?? results.length; const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw > 0 ? Math.floor(totalPagesRaw) : 1; const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw > 0 ? Math.min(Math.floor(currentPageRaw), totalPages) : page; const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? Math.floor(totalRaw) : results.length; return { results, totalPages, currentPage, total, }; }, });