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 5aa5129865
commit 659edfa68e

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 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<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
* @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<VideoGenerationResult> {
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<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) {
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);