feat(video): adaptar video-service a la H3 API (ref2va) — dormante hasta go-live

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) <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-08-11 11:12:24 +02:00
parent f2ea2f45c3
commit b066b5049e

View File

@@ -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 H3_BASE_URL = (process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, '');
const ARK_BASE_URL = 'https://ark.ap-southeast.bytepluses.com/api/v3'; const H3_API_KEY = process.env.H3API_KEY || '';
const MODEL = 'seedance-1-5-pro-251215';
// Pricing - Update based on BytePlus Ark actual pricing // Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a
// Placeholder: $0.10 per video generation (adjust as needed) // Seedance) se activará cambiando esto cuando essia-server lo valide — mismo contrato.
const COST_PER_VIDEO_USD = 0.10; const DEFAULT_I2V_MODE = 'ref2va';
interface ArkTaskResponse { // GPU propia self-hosted: sin tarifa por clip.
id: string; const COST_PER_VIDEO_USD = 0;
model: string;
status: 'pending' | 'processing' | 'succeeded' | 'failed'; // Polling tolerante (sin pings agresivos): 45s. Tope de guarda para no colgar la request de forma
created_at: number; // literalmente infinita; los jobs en ventana resuelven en minutos. Fuera de ventana → worker (ver cabecera).
updated_at: number; const POLL_INTERVAL_MS = 45_000;
content?: Array<{ const MAX_POLL_MS = 2 * 60 * 60 * 1000; // 2h
type: string;
video_url?: { url: string }; type H3JobStatus = 'queued' | 'processing' | 'done' | 'error';
}> | {
video_url?: { url: string }; interface H3JobResponse {
}; job_id: string;
error?: { status: H3JobStatus;
code: string; video_url?: string;
message: string; generation_time_seconds?: number;
}; error?: string | null;
} }
export interface VideoGenerationResult { export interface VideoGenerationResult {
@@ -35,150 +50,122 @@ export interface VideoGenerationResult {
fullPrompt: string; fullPrompt: string;
} }
function authHeaders(extra?: Record<string, string>): Record<string, string> {
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 * Genera un vídeo a partir de una imagen (image-to-video) vía la H3 API.
* @param imageUrl - URL or data URL (base64) of the reference image * Firma compatible con el servicio anterior (Ark) para que el route no cambie.
* @param prompt - Text prompt describing the action/movement *
* @param duration - Video duration in seconds (default 5) * @param imageUrl data URL base64 de la imagen del plano, o URL https accesible por el server.
* @param cameraFixed - Whether camera should be fixed (default false) * @param prompt Prompt de movimiento (SOLO positivo; se recomienda movimiento mínimo).
* @param generateAudio - Whether to generate audio (default true) * @param duration Duración en segundos (4-15, default 5).
* @returns Video generation result with URL, time, and cost * @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( export async function generateVideo(
imageUrl: string, imageUrl: string,
prompt: string, prompt: string,
duration: number = 5, duration: number = 5,
cameraFixed: boolean = false, cameraFixed: boolean = false,
generateAudio: boolean = true _generateAudio: boolean = true
): Promise<VideoGenerationResult> { ): Promise<VideoGenerationResult> {
const startTime = Date.now(); const startTime = Date.now();
if (!ARK_API_KEY) { if (!H3_API_KEY) {
throw new Error('ARK_API_KEY no configurada en variables de entorno'); throw new Error('H3API_KEY no configurada en variables de entorno');
} }
// Enhance prompt to request only natural sounds, no background music const dur = Math.min(15, Math.max(4, duration || 5));
const audioInstruction = generateAudio const fullPrompt = cameraFixed
? 'Natural ambient sounds and action sounds only, no background music.' ? `${prompt} Minimal camera movement, subtle motion only.`
: ''; : prompt;
// Compose the text prompt with audio instructions // Envío como multipart (imagen fichero) o, si es una URL http, como campo image_url.
const textPrompt = audioInstruction const form = new FormData();
? `${prompt} ${audioInstruction} --duration ${duration} --camerafixed ${cameraFixed}` form.append('mode', DEFAULT_I2V_MODE);
: `${prompt} --duration ${duration} --camerafixed ${cameraFixed}`; 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(`${H3_BASE_URL}/v1/jobs`, {
const createResponse = await fetch(`${ARK_BASE_URL}/contents/generations/tasks`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders(), // no fijar Content-Type: fetch pone el boundary del multipart
'Content-Type': 'application/json', body: form,
'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,
}),
}); });
if (!createResponse.ok) { if (!createResponse.ok) {
const errorData = await createResponse.json().catch(() => ({})); const errorData = await createResponse.text().catch(() => '');
throw new Error(`Error creando tarea de video: ${createResponse.status} ${JSON.stringify(errorData)}`); throw new Error(`Error creando job de vídeo H3: ${createResponse.status} ${errorData}`);
} }
const createData: ArkTaskResponse = await createResponse.json(); const createData = (await createResponse.json()) as H3JobResponse;
const taskId = createData.id; 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 statusResponse = await fetch(`${H3_BASE_URL}/v1/jobs/${jobId}`, {
const maxAttempts = 300; // 5 minutes with 1s intervals headers: authHeaders(),
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}`,
},
}); });
if (!statusResponse.ok) { 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(); const job = (await statusResponse.json()) as H3JobResponse;
console.log(`[Video Service] Task ${taskId} status: ${statusData.status}`); console.log(`[Video Service/H3] Job ${jobId} status: ${job.status}`);
if (statusData.status === 'succeeded') { if (job.status === 'done') {
// Log the full response to understand its structure if (!job.video_url) {
console.log('[Video Service] Success response:', JSON.stringify(statusData, null, 2)); throw new Error(`Job H3 done sin video_url: ${JSON.stringify(job)}`);
// 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;
} }
const generationTimeSeconds =
if (!videoUrl) { job.generation_time_seconds ?? (Date.now() - startTime) / 1000;
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;
return { return {
videoUrl, videoUrl: job.video_url,
generationTimeSeconds, generationTimeSeconds,
costUsd: COST_PER_VIDEO_USD, costUsd: COST_PER_VIDEO_USD,
fullPrompt: textPrompt, fullPrompt,
}; };
} else if (statusData.status === 'failed') { }
const errorMsg = statusData.error?.message || 'Error desconocido'; if (job.status === 'error') {
throw new Error(`Generación de video falló: ${errorMsg}`); throw new Error(`Generación de vídeo H3 falló: ${job.error || 'error desconocido'}`);
}
// queued | processing → seguir esperando
} }
attempts++; 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.'
throw new Error('Timeout esperando generación de video (5 minutos)'); );
} }
/** /**
* 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<Buffer> { export async function downloadVideo(url: string): Promise<Buffer> {
const response = await fetch(url); const isH3 = url.startsWith(H3_BASE_URL);
const response = await fetch(url, isH3 ? { headers: authHeaders() } : undefined);
if (!response.ok) { 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(); const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer); return Buffer.from(arrayBuffer);