From 68c164db976b5fa249af850ea9eedb950f38b8fb Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Tue, 11 Aug 2026 11:12:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(video):=20adaptar=20video-service=20a=20la?= =?UTF-8?q?=20H3=20API=20(ref2va)=20=E2=80=94=20dormante=20hasta=20go-live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye BytePlus Ark/Seedance por el contrato async de essia-server (h3api.essia.coop): POST /v1/jobs (multipart, mode=ref2va) -> poll GET /v1/jobs/{id} -> download /video (X-API-Key). Firma de generateVideo/downloadVideo sin cambios (el route no se toca). Env: H3API_BASE_URL, H3API_KEY. Coste 0 (self-hosted). Sin key no rompe el arranque (lazy). Limitación anotada: jobs pedidos fuera de la ventana nocturna pueden tardar horas; el polling síncrono solo es viable en ventana. Cola SQLite + worker pendiente de decidir UX. NO mergear a master hasta que essia-server confirme "live" + provea H3API_KEY. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/video-service.ts | 241 ++++++++++++++++++--------------------- 1 file changed, 114 insertions(+), 127 deletions(-) diff --git a/src/lib/video-service.ts b/src/lib/video-service.ts index c357419..01a4a60 100644 --- a/src/lib/video-service.ts +++ b/src/lib/video-service.ts @@ -1,31 +1,46 @@ /** - * Video generation service using BytePlus Ark API with Seedance 1.5 Pro + * Video generation service — H3 async video API (h3api.essia.coop). + * + * Sustituye a BytePlus Ark / Seedance. Contrato definido por essia-server (2026-08-11): + * POST /v1/jobs (multipart o json) -> 202 { job_id, status:"queued" } + * GET /v1/jobs/{id} -> { status, video_url?, generation_time_seconds?, error? } + * GET /v1/jobs/{id}/video -> binario mp4 (con audio nativo) + * Auth: header X-API-Key. Coste 0 (GPU self-hosted, sin tarifa por clip). + * + * ESTADO: dormante hasta que essia-server confirme "live" + provea H3API_KEY. + * Sin esas env vars, generateVideo() lanza error solo si se invoca (no rompe el arranque). + * + * ⚠️ LIMITACIÓN CONOCIDA (ventana nocturna): un job enviado de día queda `queued` y solo se + * procesa en la ventana nocturna del server (puede tardar HORAS). Este adaptador mantiene la + * request abierta con polling, lo cual solo es viable si el job se pide DENTRO de la ventana + * (resuelve en ~6-12 min). Para submissions fuera de ventana lo correcto es persistir el job en + * SQLite (status=queued) y drenarlo con un worker en background; ese cambio queda pendiente de + * decidir la UX de "vídeo en cola" con Carlos/essia-server. Ver MAX_POLL_MS. */ -const ARK_API_KEY = process.env.ARK_API_KEY || ''; -const ARK_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3'; -const MODEL = 'seedance-1-5-pro-251215'; +const H3_BASE_URL = (process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, ''); +const H3_API_KEY = process.env.H3API_KEY || ''; -// Pricing - Update based on BytePlus Ark actual pricing -// Placeholder: $0.10 per video generation (adjust as needed) -const COST_PER_VIDEO_USD = 0.10; +// Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a +// Seedance) se activará cambiando esto cuando essia-server lo valide — mismo contrato. +const DEFAULT_I2V_MODE = 'ref2va'; -interface ArkTaskResponse { - id: string; - model: string; - status: 'pending' | 'processing' | 'succeeded' | 'failed'; - created_at: number; - updated_at: number; - content?: Array<{ - type: string; - video_url?: { url: string }; - }> | { - video_url?: { url: string }; - }; - error?: { - code: string; - message: string; - }; +// GPU propia self-hosted: sin tarifa por clip. +const COST_PER_VIDEO_USD = 0; + +// Polling tolerante (sin pings agresivos): 45s. Tope de guarda para no colgar la request de forma +// literalmente infinita; los jobs en ventana resuelven en minutos. Fuera de ventana → worker (ver cabecera). +const POLL_INTERVAL_MS = 45_000; +const MAX_POLL_MS = 2 * 60 * 60 * 1000; // 2h + +type H3JobStatus = 'queued' | 'processing' | 'done' | 'error'; + +interface H3JobResponse { + job_id: string; + status: H3JobStatus; + video_url?: string; + generation_time_seconds?: number; + error?: string | null; } export interface VideoGenerationResult { @@ -35,150 +50,122 @@ export interface VideoGenerationResult { fullPrompt: string; } +function authHeaders(extra?: Record): Record { + return { 'X-API-Key': H3_API_KEY, ...(extra || {}) }; +} + +/** Convierte un data URL (data:image/png;base64,...) a Blob para multipart. */ +function dataUrlToBlob(dataUrl: string): Blob { + const match = /^data:([^;]+);base64,([\s\S]*)$/.exec(dataUrl); + if (!match) { + throw new Error('imageUrl no es un data URL base64 válido'); + } + const [, mime, b64] = match; + return new Blob([Buffer.from(b64, 'base64')], { type: mime }); +} + /** - * Generate video from image using Seedance 1.5 Pro - * @param imageUrl - URL or data URL (base64) of the reference image - * @param prompt - Text prompt describing the action/movement - * @param duration - Video duration in seconds (default 5) - * @param cameraFixed - Whether camera should be fixed (default false) - * @param generateAudio - Whether to generate audio (default true) - * @returns Video generation result with URL, time, and cost + * Genera un vídeo a partir de una imagen (image-to-video) vía la H3 API. + * Firma compatible con el servicio anterior (Ark) para que el route no cambie. + * + * @param imageUrl data URL base64 de la imagen del plano, o URL https accesible por el server. + * @param prompt Prompt de movimiento (SOLO positivo; se recomienda movimiento mínimo). + * @param duration Duración en segundos (4-15, default 5). + * @param cameraFixed Si true, añade indicación de cámara/movimiento mínimo al prompt. + * @param _generateAudio Ignorado: H3 genera audio nativo siempre (se normaliza en post). */ export async function generateVideo( imageUrl: string, prompt: string, duration: number = 5, cameraFixed: boolean = false, - generateAudio: boolean = true + _generateAudio: boolean = true ): Promise { const startTime = Date.now(); - if (!ARK_API_KEY) { - throw new Error('ARK_API_KEY no configurada en variables de entorno'); + if (!H3_API_KEY) { + throw new Error('H3API_KEY no configurada en variables de entorno'); } - // Enhance prompt to request only natural sounds, no background music - const audioInstruction = generateAudio - ? 'Natural ambient sounds and action sounds only, no background music.' - : ''; + const dur = Math.min(15, Math.max(4, duration || 5)); + const fullPrompt = cameraFixed + ? `${prompt} Minimal camera movement, subtle motion only.` + : prompt; - // Compose the text prompt with audio instructions - const textPrompt = audioInstruction - ? `${prompt} ${audioInstruction} --duration ${duration} --camerafixed ${cameraFixed}` - : `${prompt} --duration ${duration} --camerafixed ${cameraFixed}`; + // Envío como multipart (imagen fichero) o, si es una URL http, como campo image_url. + const form = new FormData(); + form.append('mode', DEFAULT_I2V_MODE); + form.append('prompt', fullPrompt); + form.append('width', '832'); // 16:9; usar 480 para 9:16 + form.append('height', '480'); + form.append('duration', String(dur)); + if (imageUrl.startsWith('data:')) { + form.append('image', dataUrlToBlob(imageUrl), 'plano.png'); + } else { + form.append('image_url', imageUrl); + } - // Create video generation task - const createResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks`, { + const createResponse = await fetch(`${H3_BASE_URL}/v1/jobs`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${ARK_API_KEY}`, - }, - body: JSON.stringify({ - model: MODEL, - content: [ - { - type: 'text', - text: textPrompt, - }, - { - type: 'image_url', - image_url: { - url: imageUrl, - }, - }, - ], - // Additional parameters for video generation - duration, - resolution: '720p', - aspect_ratio: '16:9', - fps: 24, - camera_fixed: cameraFixed, - generate_audio: generateAudio, - }), + headers: authHeaders(), // no fijar Content-Type: fetch pone el boundary del multipart + body: form, }); if (!createResponse.ok) { - const errorData = await createResponse.json().catch(() => ({})); - throw new Error(`Error creando tarea de video: ${createResponse.status} ${JSON.stringify(errorData)}`); + const errorData = await createResponse.text().catch(() => ''); + throw new Error(`Error creando job de vídeo H3: ${createResponse.status} ${errorData}`); } - const createData: ArkTaskResponse = await createResponse.json(); - const taskId = createData.id; + const createData = (await createResponse.json()) as H3JobResponse; + const jobId = createData.job_id; + console.log(`[Video Service/H3] Job creado: ${jobId} (status ${createData.status})`); - console.log(`[Video Service] Task created: ${taskId}`); + // Polling hasta done|error (ver limitación de ventana en la cabecera del fichero). + while (Date.now() - startTime < MAX_POLL_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - // Poll task status until completed (max 5 minutes) - const maxAttempts = 300; // 5 minutes with 1s intervals - let attempts = 0; - - while (attempts < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second - - const statusResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks/${taskId}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${ARK_API_KEY}`, - }, + const statusResponse = await fetch(`${H3_BASE_URL}/v1/jobs/${jobId}`, { + headers: authHeaders(), }); - if (!statusResponse.ok) { - throw new Error(`Error consultando estado de tarea: ${statusResponse.status}`); + throw new Error(`Error consultando job H3: ${statusResponse.status}`); } - const statusData: ArkTaskResponse = await statusResponse.json(); - console.log(`[Video Service] Task ${taskId} status: ${statusData.status}`); + const job = (await statusResponse.json()) as H3JobResponse; + console.log(`[Video Service/H3] Job ${jobId} status: ${job.status}`); - if (statusData.status === 'succeeded') { - // Log the full response to understand its structure - console.log('[Video Service] Success response:', JSON.stringify(statusData, null, 2)); - - // Extract video URL from response - // Handle both array and object formats - let videoUrl: string | undefined; - - if (Array.isArray(statusData.content)) { - // Array format: [{ type: 'video_url', video_url: { url: '...' } }] - const videoContent = statusData.content.find(c => c.type === 'video_url' && c.video_url); - videoUrl = videoContent?.video_url?.url; - } else if (statusData.content?.video_url) { - // Object format: { video_url: "https://..." } (direct string) - const vurl = (statusData.content as any).video_url; - videoUrl = typeof vurl === 'string' ? vurl : vurl?.url; + if (job.status === 'done') { + if (!job.video_url) { + throw new Error(`Job H3 done sin video_url: ${JSON.stringify(job)}`); } - - if (!videoUrl) { - throw new Error(`Video URL no encontrada en respuesta exitosa. Response: ${JSON.stringify(statusData)}`); - } - - console.log(`[Video Service] Task ${taskId} succeeded: ${videoUrl}`); - - const endTime = Date.now(); - const generationTimeSeconds = (endTime - startTime) / 1000; - + const generationTimeSeconds = + job.generation_time_seconds ?? (Date.now() - startTime) / 1000; return { - videoUrl, + videoUrl: job.video_url, generationTimeSeconds, costUsd: COST_PER_VIDEO_USD, - fullPrompt: textPrompt, + fullPrompt, }; - } else if (statusData.status === 'failed') { - const errorMsg = statusData.error?.message || 'Error desconocido'; - throw new Error(`Generación de video falló: ${errorMsg}`); } - - attempts++; + if (job.status === 'error') { + throw new Error(`Generación de vídeo H3 falló: ${job.error || 'error desconocido'}`); + } + // queued | processing → seguir esperando } - throw new Error('Timeout esperando generación de video (5 minutos)'); + throw new Error( + 'Timeout esperando el vídeo H3 (tope de guarda). Si se pidió fuera de la ventana nocturna, ' + + 'requiere procesamiento diferido con worker en background.' + ); } /** - * Download video from URL and return as Buffer + * Descarga el vídeo generado. El video_url de H3 requiere la X-API-Key. */ export async function downloadVideo(url: string): Promise { - const response = await fetch(url); + const isH3 = url.startsWith(H3_BASE_URL); + const response = await fetch(url, isH3 ? { headers: authHeaders() } : undefined); if (!response.ok) { - throw new Error(`Error descargando video: ${response.status}`); + throw new Error(`Error descargando vídeo: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer);