fix: normalize video provider durations

This commit is contained in:
Peter Steinberger
2026-04-05 23:44:09 +01:00
parent 5cff2ff94b
commit fdf381f1a7
4 changed files with 88 additions and 7 deletions

View File

@@ -116,4 +116,42 @@ describe("google video generation provider", () => {
}),
).rejects.toThrow("Google video generation does not support image and video inputs together.");
});
it("rounds unsupported durations to the nearest Veo value", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key",
source: "env",
mode: "api-key",
});
generateVideosMock.mockResolvedValue({
done: true,
response: {
generatedVideos: [
{
video: {
videoBytes: Buffer.from("mp4-bytes").toString("base64"),
mimeType: "video/mp4",
},
},
],
},
});
const provider = buildGoogleVideoGenerationProvider();
await provider.generateVideo({
provider: "google",
model: "veo-3.1-fast-generate-preview",
prompt: "A tiny robot watering a windowsill garden",
cfg: {},
durationSeconds: 5,
});
expect(generateVideosMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
durationSeconds: 6,
}),
}),
);
});
});

View File

@@ -15,8 +15,10 @@ const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_POLL_ATTEMPTS = 90;
const GOOGLE_VIDEO_MIN_DURATION_SECONDS = 4;
const GOOGLE_VIDEO_MAX_DURATION_SECONDS = 8;
const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined {
const configured = req.cfg?.models?.providers?.google?.baseUrl?.trim();
@@ -75,10 +77,21 @@ function resolveDurationSeconds(durationSeconds: number | undefined): number | u
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds)) {
return undefined;
}
return Math.min(
const rounded = Math.min(
GOOGLE_VIDEO_MAX_DURATION_SECONDS,
Math.max(GOOGLE_VIDEO_MIN_DURATION_SECONDS, Math.round(durationSeconds)),
);
return GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.reduce((best, current) => {
const currentDistance = Math.abs(current - rounded);
const bestDistance = Math.abs(best - rounded);
if (currentDistance < bestDistance) {
return current;
}
if (currentDistance === bestDistance && current > best) {
return current;
}
return best;
});
}
function resolveInputImage(req: VideoGenerationRequest) {

View File

@@ -71,11 +71,15 @@ describe("minimax video generation provider", () => {
model: "MiniMax-Hailuo-2.3",
prompt: "A fox sprints across snowy hills",
cfg: {},
durationSeconds: 5,
});
expect(postJsonRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://api.minimax.io/v1/video_generation",
body: expect.objectContaining({
duration: 6,
}),
}),
);
expect(result.videos).toHaveLength(1);

View File

@@ -17,6 +17,10 @@ const DEFAULT_MINIMAX_VIDEO_MODEL = "MiniMax-Hailuo-2.3";
const DEFAULT_TIMEOUT_MS = 120_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_POLL_ATTEMPTS = 90;
const MINIMAX_MODEL_ALLOWED_DURATIONS: Readonly<Record<string, readonly number[]>> = {
"MiniMax-Hailuo-2.3": [6, 10],
"MiniMax-Hailuo-02": [6, 10],
};
type MinimaxBaseResp = {
status_code?: number;
@@ -85,6 +89,23 @@ function resolveFirstFrameImage(req: VideoGenerationRequest): string | undefined
return toDataUrl(input.buffer, input.mimeType?.trim() || "image/png");
}
function resolveDurationSeconds(params: {
model: string;
durationSeconds: number | undefined;
}): number | undefined {
if (typeof params.durationSeconds !== "number" || !Number.isFinite(params.durationSeconds)) {
return undefined;
}
const rounded = Math.max(1, Math.round(params.durationSeconds));
const allowed = MINIMAX_MODEL_ALLOWED_DURATIONS[params.model];
if (!allowed || allowed.length === 0) {
return rounded;
}
return allowed.reduce((best, current) =>
Math.abs(current - rounded) < Math.abs(best - rounded) ? current : best,
);
}
async function pollMinimaxVideo(params: {
taskId: string;
headers: Headers;
@@ -242,8 +263,9 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
capability: "video",
transport: "http",
});
const model = req.model?.trim() || DEFAULT_MINIMAX_VIDEO_MODEL;
const body: Record<string, unknown> = {
model: req.model?.trim() || DEFAULT_MINIMAX_VIDEO_MODEL,
model,
prompt: req.prompt,
};
const firstFrameImage = resolveFirstFrameImage(req);
@@ -253,8 +275,12 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
if (req.resolution) {
body.resolution = req.resolution;
}
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
body.duration = Math.max(1, Math.round(req.durationSeconds));
const durationSeconds = resolveDurationSeconds({
model,
durationSeconds: req.durationSeconds,
});
if (typeof durationSeconds === "number") {
body.duration = durationSeconds;
}
const { response, release } = await postJsonRequest({
url: `${baseUrl}/v1/video_generation`,
@@ -303,7 +329,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
})();
return {
videos: [video],
model: req.model?.trim() || DEFAULT_MINIMAX_VIDEO_MODEL,
model,
metadata: {
taskId,
status: completed.status,