- route videos: 16:9=1344x768 / 9:16=768x1344 (antes 832x480), el máximo legal del contrato H3 (w*h<=1.032.192px, múltiplos de 32). Es la resolución estándar ahora. - video-service: normalizeResolution ajusta cualquier resolución al contrato H3. - mcp: story_sync_video_jobs + proyección compacta (capítulos de 100+ planos). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
7.1 KiB
TypeScript
187 lines
7.1 KiB
TypeScript
/**
|
|
* Video generation service — H3 async video API (h3api.essia.coop).
|
|
*
|
|
* Sustituye a BytePlus Ark / Seedance. Contrato definido por essia-server (2026-08):
|
|
* 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).
|
|
*
|
|
* La cola + worker viven SERVER-SIDE (h3api): `submitVideoJob` devuelve un job_id al instante
|
|
* (status=queued) y el server lo drena en la ventana nocturna. Aquí NO se bloquea la request:
|
|
* el ciclo submit → poll → descarga lo orquesta el route de vídeos de forma async (persistiendo
|
|
* el job_id en `video_variations` y refrescando estado bajo demanda desde el cliente).
|
|
*
|
|
* ESTADO: dormante hasta que essia-server confirme "live" + provea H3API_KEY. Sin esas env vars,
|
|
* submitVideoJob() lanza error solo si se invoca (no rompe el arranque).
|
|
*/
|
|
|
|
// essia-server entrega la env como H3API_URL; aceptamos también H3API_BASE_URL por compatibilidad.
|
|
const H3_BASE_URL = (process.env.H3API_URL || process.env.H3API_BASE_URL || 'https://h3api.essia.coop').replace(/\/$/, '');
|
|
const H3_API_KEY = process.env.H3API_KEY || '';
|
|
|
|
// Modo i2v del MVP: ref2va (imagen->vídeo, validado). fl2va (primer frame exacto, más fiel a
|
|
// Seedance) se activará pasando mode:'fl2va' cuando essia-server lo valide — mismo contrato.
|
|
const DEFAULT_I2V_MODE = 'ref2va';
|
|
|
|
// GPU propia self-hosted: sin tarifa por clip.
|
|
const COST_PER_VIDEO_USD = 0;
|
|
|
|
export type H3JobStatus = 'queued' | 'processing' | 'done' | 'error';
|
|
|
|
export interface H3JobStatusResult {
|
|
status: H3JobStatus;
|
|
videoUrl?: string;
|
|
generationTimeSeconds?: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface H3JobResponse {
|
|
job_id: string;
|
|
status: H3JobStatus;
|
|
video_url?: string;
|
|
generation_time_seconds?: number;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface SubmitVideoOptions {
|
|
duration?: number; // segundos, 4-15 (default 5)
|
|
cameraFixed?: boolean; // añade indicación de movimiento mínimo al prompt
|
|
width?: number; // se ajusta a la rejilla de 32 px de H3 (default 832)
|
|
height?: number; // ídem (default 480); tope H3: width*height <= 1.032.192 px
|
|
mode?: string; // 'ref2va' (default) | 'fl2va' | 't2va'
|
|
}
|
|
|
|
// Contrato de resolución del nodo H3 (verificado 2026-08-17 contra H3ContractError del RH node):
|
|
// width/height deben ser múltiplos de 32 y width*height <= 1_032_192 px (= 1344x768, su máximo
|
|
// ~16:9). Fuera de eso el render falla a los pocos segundos ya en el server, así que ajustamos
|
|
// aquí para que ningún caller pueda encolar un job imposible (p.ej. 1280x720 o 1920x1088).
|
|
const H3_MAX_PIXELS = 1_032_192;
|
|
|
|
function normalizeResolution(width: number, height: number): { width: number; height: number } {
|
|
const snap = (v: number) => Math.max(32, Math.round(v / 32) * 32);
|
|
let w = snap(width);
|
|
let h = snap(height);
|
|
if (w * h > H3_MAX_PIXELS) {
|
|
const scale = Math.sqrt(H3_MAX_PIXELS / (w * h));
|
|
w = snap(w * scale);
|
|
h = snap(h * scale);
|
|
while (w * h > H3_MAX_PIXELS) {
|
|
if (w >= h) w -= 32;
|
|
else h -= 32;
|
|
}
|
|
}
|
|
return { width: w, height: h };
|
|
}
|
|
|
|
export const H3_COST_USD = COST_PER_VIDEO_USD;
|
|
|
|
function ensureConfigured(): void {
|
|
if (!H3_API_KEY) {
|
|
throw new Error('H3API_KEY no configurada en variables de entorno');
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* Encola un job de image-to-video en la H3 API y devuelve su id al instante (NO espera al render).
|
|
*
|
|
* @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).
|
|
* @returns { jobId, fullPrompt } — persiste jobId y consulta el estado con getJobStatus().
|
|
*/
|
|
export async function submitVideoJob(
|
|
imageUrl: string,
|
|
prompt: string,
|
|
options: SubmitVideoOptions = {}
|
|
): Promise<{ jobId: string; fullPrompt: string }> {
|
|
ensureConfigured();
|
|
|
|
const dur = Math.min(15, Math.max(4, options.duration || 5));
|
|
const requestedW = options.width ?? 832;
|
|
const requestedH = options.height ?? 480;
|
|
const { width, height } = normalizeResolution(requestedW, requestedH);
|
|
if (width !== requestedW || height !== requestedH) {
|
|
console.log(`[Video Service/H3] Resolución ajustada al contrato H3: ${requestedW}x${requestedH} -> ${width}x${height}`);
|
|
}
|
|
const mode = options.mode || DEFAULT_I2V_MODE;
|
|
const fullPrompt = options.cameraFixed
|
|
? `${prompt} Minimal camera movement, subtle motion only.`
|
|
: prompt;
|
|
|
|
const form = new FormData();
|
|
form.append('mode', mode);
|
|
form.append('prompt', fullPrompt);
|
|
form.append('width', String(width));
|
|
form.append('height', String(height));
|
|
form.append('duration', String(dur));
|
|
if (imageUrl.startsWith('data:')) {
|
|
form.append('image', dataUrlToBlob(imageUrl), 'plano.png');
|
|
} else {
|
|
form.append('image_url', imageUrl);
|
|
}
|
|
|
|
const res = await fetch(`${H3_BASE_URL}/v1/jobs`, {
|
|
method: 'POST',
|
|
headers: authHeaders(), // no fijar Content-Type: fetch pone el boundary del multipart
|
|
body: form,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorData = await res.text().catch(() => '');
|
|
throw new Error(`Error creando job de vídeo H3: ${res.status} ${errorData}`);
|
|
}
|
|
|
|
const data = (await res.json()) as H3JobResponse;
|
|
if (!data.job_id) {
|
|
throw new Error(`Respuesta de H3 sin job_id: ${JSON.stringify(data)}`);
|
|
}
|
|
console.log(`[Video Service/H3] Job encolado: ${data.job_id} (status ${data.status})`);
|
|
return { jobId: data.job_id, fullPrompt };
|
|
}
|
|
|
|
/**
|
|
* Consulta el estado de un job H3 (una sola llamada, sin polling). El polling lo hace el cliente
|
|
* llamando al route de sync bajo demanda (no bloquea ninguna request de servidor).
|
|
*/
|
|
export async function getJobStatus(jobId: string): Promise<H3JobStatusResult> {
|
|
ensureConfigured();
|
|
const res = await fetch(`${H3_BASE_URL}/v1/jobs/${jobId}`, { headers: authHeaders() });
|
|
if (!res.ok) {
|
|
throw new Error(`Error consultando job H3 ${jobId}: ${res.status}`);
|
|
}
|
|
const job = (await res.json()) as H3JobResponse;
|
|
return {
|
|
status: job.status,
|
|
videoUrl: job.video_url,
|
|
generationTimeSeconds: job.generation_time_seconds,
|
|
error: job.error || undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Descarga el vídeo generado. El video_url de H3 requiere la X-API-Key.
|
|
*/
|
|
export async function downloadVideo(url: string): Promise<Buffer> {
|
|
const isH3 = url.startsWith(H3_BASE_URL);
|
|
const response = await fetch(url, isH3 ? { headers: authHeaders() } : undefined);
|
|
if (!response.ok) {
|
|
throw new Error(`Error descargando vídeo: ${response.status}`);
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return Buffer.from(arrayBuffer);
|
|
}
|