feat(video-studio): first/last frame support for i2v models

Adds an optional end-frame upload picker for i2v models whose server
schema accepts a second image (kling v2.1/v3.0/o1, veo3.1, seedance
lite/v1.5-pro, wan2.2, minimax-hailuo-02). Catalog entries declare the
server-side param via `lastImageField` ("last_image" or "end_image_url")
so the picker is gated per-model and unsupported models stay unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anil Matcha 2026-05-05 19:09:26 +05:30
commit 8cbaf7fc3f
3 changed files with 73 additions and 0 deletions

View file

@ -45,6 +45,7 @@ export function VideoStudio() {
let lastGenerationModel = null;
let dropdownOpen = null;
let uploadedImageUrl = null;
let uploadedEndImageUrl = null; // optional end-frame for FLF i2v models
let imageMode = false; // false = t2v models, true = i2v models
let v2vMode = false; // true = video-to-video tools mode
let uploadedVideoUrl = null;
@ -140,6 +141,9 @@ export function VideoStudio() {
onClear: () => {
uploadedImageUrl = null;
imageMode = false;
// Clearing the start frame invalidates any selected end frame.
uploadedEndImageUrl = null;
endPicker?.reset();
selectedModel = allT2V[0].id;
selectedModelName = allT2V[0].name;
document.getElementById('v-model-btn-label').textContent = selectedModelName;
@ -155,6 +159,46 @@ export function VideoStudio() {
topRow.appendChild(picker.trigger);
container.appendChild(picker.panel);
// --- End-Frame Upload Picker (FLF i2v models — kling/veo/seedance/etc.) ---
// Shown only when imageMode is on AND the selected i2v model declares a
// `lastImageField` in its catalog entry. Reuses the same UploadPicker UI;
// a corner badge differentiates it from the start-frame picker.
const endPicker = createUploadPicker({
anchorContainer: container,
onSelect: ({ url }) => { uploadedEndImageUrl = url; },
onClear: () => { uploadedEndImageUrl = null; },
uploadFn: (file) => muapi.uploadFile(file),
requireApiKey: () => true,
});
endPicker.trigger.title = 'End frame (optional)';
// Visual marker: small "L" badge in the corner so users can tell the two
// pickers apart at a glance. The wrapper keeps it from interfering with
// UploadPicker's own thumbnail/spinner state swapping.
const endBadge = document.createElement('div');
endBadge.className = 'absolute top-0.5 left-0.5 px-1 h-4 bg-white/20 rounded-md flex items-center justify-center pointer-events-none';
endBadge.innerHTML = '<span class="text-[8px] font-black text-white leading-none">END</span>';
endPicker.trigger.appendChild(endBadge);
endPicker.trigger.classList.add('hidden'); // start hidden until updateEndFrameVisibility flips it on
topRow.appendChild(endPicker.trigger);
container.appendChild(endPicker.panel);
const updateEndFrameVisibility = () => {
const model = getCurrentModel();
const supports = imageMode && !!model?.lastImageField;
if (supports) {
endPicker.trigger.classList.remove('hidden');
endPicker.trigger.classList.add('flex');
} else {
endPicker.trigger.classList.add('hidden');
endPicker.trigger.classList.remove('flex');
// Drop any stale end-frame selection when leaving FLF-capable state
if (uploadedEndImageUrl) {
uploadedEndImageUrl = null;
endPicker.reset();
}
}
};
// --- Video Upload Picker (Video-to-Video) ---
const videoFileInput = document.createElement('input');
videoFileInput.type = 'file';
@ -381,6 +425,9 @@ export function VideoStudio() {
const updateControlsForModel = (modelId) => {
const model = getCurrentModels().find(m => m.id === modelId);
// End-frame picker visibility depends on imageMode + model.lastImageField.
updateEndFrameVisibility();
// In v2v mode, hide all parameter controls — no prompt/AR/duration/etc needed
if (v2vMode) {
arBtn.style.display = 'none';
@ -1105,6 +1152,9 @@ export function VideoStudio() {
};
i2vParams.prompt = prompt || '';
i2vParams.aspect_ratio = selectedAr;
if (uploadedEndImageUrl && getCurrentModel()?.lastImageField) {
i2vParams.last_image = uploadedEndImageUrl;
}
const durations = getCurrentDurations(selectedModel);
if (durations.length > 0) i2vParams.duration = selectedDuration;
const resolutions = getCurrentResolutions(selectedModel);

View file

@ -5583,6 +5583,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-master-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5624,6 +5625,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-standard-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5665,6 +5667,7 @@ export const i2vModels = [
"endpoint": "kling-v2.1-pro-i2v",
"family": "kling-v2.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5706,6 +5709,7 @@ export const i2vModels = [
"endpoint": "wan2.2-image-to-video",
"family": "wan2.2",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5932,6 +5936,7 @@ export const i2vModels = [
"endpoint": "minimax-hailuo-02-standard-i2v",
"family": "minimax-2",
"imageField": "image_url",
"lastImageField": "end_image_url",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -5973,6 +5978,7 @@ export const i2vModels = [
"endpoint": "minimax-hailuo-02-pro-i2v",
"family": "minimax-2",
"imageField": "image_url",
"lastImageField": "end_image_url",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6072,6 +6078,7 @@ export const i2vModels = [
"endpoint": "seedance-lite-i2v",
"family": "bytedance",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6753,6 +6760,7 @@ export const i2vModels = [
"endpoint": "veo3.1-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6803,6 +6811,7 @@ export const i2vModels = [
"endpoint": "veo3.1-fast-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -6853,6 +6862,7 @@ export const i2vModels = [
"endpoint": "veo3.1-lite-image-to-video",
"family": "veo3.1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7433,6 +7443,7 @@ export const i2vModels = [
"endpoint": "kling-o1-image-to-video",
"family": "kling-o1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7753,6 +7764,7 @@ export const i2vModels = [
"endpoint": "kling-o1-standard-image-to-video",
"family": "kling-o1",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7825,6 +7837,7 @@ export const i2vModels = [
"endpoint": "seedance-v1.5-pro-i2v",
"family": "seedance-v1.5-pro",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -7895,6 +7908,7 @@ export const i2vModels = [
"endpoint": "seedance-v1.5-pro-i2v-fast",
"family": "seedance-v1.5-pro",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -8005,6 +8019,7 @@ export const i2vModels = [
"endpoint": "kling-v3.0-pro-image-to-video",
"family": "kling-v3.0",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {
@ -8041,6 +8056,7 @@ export const i2vModels = [
"endpoint": "kling-v3.0-standard-image-to-video",
"family": "kling-v3.0",
"imageField": "image_url",
"lastImageField": "last_image",
"hasPrompt": true,
"inputs": {
"prompt": {

View file

@ -326,6 +326,13 @@ export class MuapiClient {
}
}
// Optional end-frame image — only for models declaring lastImageField.
// Server-side param name varies (last_image vs end_image_url).
const lastImageField = modelInfo?.lastImageField;
if (lastImageField && params.last_image) {
finalPayload[lastImageField] = params.last_image;
}
if (params.aspect_ratio) finalPayload.aspect_ratio = params.aspect_ratio;
if (params.duration) finalPayload.duration = params.duration;
if (params.resolution) finalPayload.resolution = params.resolution;