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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user