diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 32d837b..a3de5bb 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -86,8 +86,9 @@ export async function POST(request: Request) { let bodyForLog: JsonValue; if (endpoint.contentType === 'multipart' && formData) { - // Remove our internal field + // Remove our internal fields formData.delete('_endpointId'); + formData.delete('_priceCalc'); fetchOptions = { method: endpoint.method, headers, diff --git a/src/components/EndpointForm.tsx b/src/components/EndpointForm.tsx index 0af03b1..156323c 100644 --- a/src/components/EndpointForm.tsx +++ b/src/components/EndpointForm.tsx @@ -471,15 +471,43 @@ export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }: try { const filteredValues = buildFilteredValues(); - const res = await fetch('/api/proxy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...filteredValues, - _endpointId: endpoint.id, - _priceCalc: true, - }), - }); + let res: Response; + + // For multipart endpoints with files, send as FormData (includes files for price calc) + const hasFiles = Object.keys(files).length > 0; + if (endpoint.contentType === 'multipart' && hasFiles) { + const formData = new FormData(); + formData.append('_endpointId', endpoint.id); + formData.append('_priceCalc', 'true'); + + Object.entries(filteredValues).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + formData.append(key, String(value)); + } + }); + + Object.entries(files).forEach(([key, fileOrFiles]) => { + const param = endpoint.params.find((p) => p.name === key); + if (param?.visibleWhen && !isFieldVisible(param)) return; + if (Array.isArray(fileOrFiles)) { + fileOrFiles.forEach((file) => formData.append(key, file)); + } else { + formData.append(key, fileOrFiles); + } + }); + + res = await fetch('/api/proxy', { method: 'POST', body: formData }); + } else { + res = await fetch('/api/proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...filteredValues, + _endpointId: endpoint.id, + _priceCalc: true, + }), + }); + } const data = await res.json(); diff --git a/src/lib/endpoint-registry.ts b/src/lib/endpoint-registry.ts index 04de839..0eba027 100644 --- a/src/lib/endpoint-registry.ts +++ b/src/lib/endpoint-registry.ts @@ -14,7 +14,7 @@ export const ENDPOINT_GROUPS: EndpointGroupMeta[] = [ id: 'video-generation', label: 'Video Generation', icon: '🎬', - description: 'Text-to-Video, Image-to-Video', + description: 'Text-to-Video, Image-to-Video, Video Replace', }, { id: 'audio-generation', @@ -370,6 +370,55 @@ export const ENDPOINTS: EndpointDefinition[] = [ ], }, + { + id: 'video-replace', + name: 'Video Replace', + group: 'video-generation', + method: 'POST', + path: '/videos/replace', + description: 'Replace a person in video with character from reference image', + contentType: 'multipart', + isAsync: true, + hasPriceCalc: true, + priceCalcPath: '/videos/replace/price', + params: [ + { + name: 'video', + label: 'Input Video', + type: 'file', + required: true, + accept: 'video/*,.mp4,.mpeg,.mov,.avi,.wmv,.ogg', + description: 'Input video file (MP4, MPEG, QuickTime, AVI, WMV, OGG)', + }, + { + name: 'ref_image', + label: 'Reference Image', + type: 'file', + required: true, + accept: 'image/*', + description: 'Reference character image (JPG, PNG, GIF, BMP, WebP, max 10MB)', + }, + promptParam(false), + modelSelectParam(), + { + name: 'width', + label: 'Width', + type: 'number', + required: false, + description: 'Output width in pixels (empty = use input video width)', + }, + { + name: 'height', + label: 'Height', + type: 'number', + required: false, + description: 'Output height in pixels (empty = use input video height)', + }, + stepsParam(), + seedParam(), + ], + }, + // ── Audio / TTS ─────────────────────────────────────────── { id: 'txt2audio',