From ac126a61b9288d292a4a5a70ba5f8d3752b80f3f Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 27 Jul 2026 17:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20add=20support=20for=20=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=E6=96=B9=E8=88=9F=20protocol=20and=20enhance=20video?= =?UTF-8?q?=20reference=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/content/docs/progress/pending-test.mdx | 1 + .../components/layout/app-config-modal.tsx | 4 ++- .../layout/channel-editor-drawer.tsx | 1 + web/src/components/video-settings-panel.tsx | 21 +++++-------- web/src/lib/seedance-video.ts | 31 ++++++------------- web/src/pages/video/index.tsx | 8 ++--- web/src/services/api/image.ts | 24 ++------------ web/src/services/api/video.ts | 2 +- web/src/stores/use-config-store.ts | 9 ++++-- 10 files changed, 37 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f2231..709437a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ++ [新增] 模型渠道新增火山方舟协议。 + [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。 ## v0.10.0 - 2026-07-25 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 2dd9968..41ab8dd 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 - 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。 - 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。 - 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。 diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index 9d02330..a3f3ed8 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -357,7 +357,9 @@ function normalizeImageCount(value: string) { } function apiFormatLabel(apiFormat: ApiCallFormat) { - return apiFormat === "gemini" ? "Gemini" : "OpenAI"; + if (apiFormat === "gemini") return "Gemini"; + if (apiFormat === "ark") return "火山方舟"; + return "OpenAI"; } function formatWebdavTime(value: string) { diff --git a/web/src/components/layout/channel-editor-drawer.tsx b/web/src/components/layout/channel-editor-drawer.tsx index 5f73e2c..1eef654 100644 --- a/web/src/components/layout/channel-editor-drawer.tsx +++ b/web/src/components/layout/channel-editor-drawer.tsx @@ -9,6 +9,7 @@ import { ModelSelectModal } from "./model-select-modal"; const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [ { label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }, + { label: "火山方舟", value: "ark" }, ]; const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [ diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 9933347..b6963a4 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -2,9 +2,9 @@ import { type ReactNode } from "react"; import { Switch } from "antd"; import { ImageSettingsTheme } from "@/components/image-settings-panel"; -import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; +import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; import { type CanvasTheme } from "@/lib/canvas-theme"; -import { modelOptionName, type AiConfig } from "@/stores/use-config-store"; +import { type AiConfig } from "@/stores/use-config-store"; const resolutionOptions = [ { value: "720", label: "720p" }, @@ -105,8 +105,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = } function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { - const model = modelOptionName(config.model || config.videoModel); - const resolution = normalizeSeedanceResolution(config.vquality, model); + const resolution = normalizeSeedanceResolution(config.vquality); const ratio = normalizeSeedanceRatio(config.size); const duration = normalizeSeedanceDuration(config.videoSeconds); const generateAudio = boolConfig(config.videoGenerateAudio, true); @@ -118,16 +117,12 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, {showTitle ?
视频设置
: null}
- {seedanceResolutionOptions.map((item) => { - const disabled = item.value === "1080p" && isSeedanceFastModel(model); - return ( - onConfigChange("vquality", item.value)}> - {item.label} - - ); - })} + {seedanceResolutionOptions.map((item) => ( + onConfigChange("vquality", item.value)}> + {item.label} + + ))}
- {isSeedanceFastModel(model) ?
fast 模型不支持 1080p,会自动使用 720p。
: null}
diff --git a/web/src/lib/seedance-video.ts b/web/src/lib/seedance-video.ts index 27fe332..898a045 100644 --- a/web/src/lib/seedance-video.ts +++ b/web/src/lib/seedance-video.ts @@ -1,4 +1,4 @@ -import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; +import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -7,9 +7,10 @@ export const SEEDANCE_REFERENCE_LIMITS = { videos: 3, audios: 3, imageMaxBytes: 30 * 1024 * 1024, - videoMaxBytes: 50 * 1024 * 1024, + videoMaxBytes: 200 * 1024 * 1024, audioMaxBytes: 15 * 1024 * 1024, }; +export const SEEDANCE_VIDEO_MIME_TYPES = ["video/mp4", "video/quicktime"]; export const seedanceResolutionOptions = [ { value: "480p", label: "480p" }, @@ -56,28 +57,13 @@ const seedancePixels = { }, } as const; -export function isSeedanceVideoConfig(config: AiConfig | Pick) { +export function isSeedanceVideoConfig(config: AiConfig | Pick) { const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config; - return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl); + return requestConfig.apiFormat === "ark"; } -export function isSeedanceVideoModel(model: string) { - const value = model.toLowerCase(); - return value.includes("seedance") || value.includes("doubao-seedance"); -} - -export function isSeedanceFastModel(model: string) { - const value = model.toLowerCase(); - return isSeedanceVideoModel(value) && value.includes("fast"); -} - -export function isArkPlanBaseUrl(baseUrl: string) { - return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3"); -} - -export function normalizeSeedanceResolution(value: string, model = "") { +export function normalizeSeedanceResolution(value: string) { const normalized = normalizeResolutionToken(value); - if (isSeedanceFastModel(model) && normalized === "1080p") return "720p"; return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p"; } @@ -149,7 +135,8 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) { for (let index = 0; index < videos.length; index += 1) { const video = videos[index]; const label = seedanceReferenceLabel("video", index); - if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`; + if (!SEEDANCE_VIDEO_MIME_TYPES.includes(video.type)) return `${label} 仅支持 mp4/mov 格式`; + if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 200MB,请压缩后再上传`; if (video.durationMs) { if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`; totalDurationMs += video.durationMs; @@ -159,7 +146,7 @@ export function seedanceVideoReferenceError(videos: ReferenceVideo[]) { const ratio = video.width / video.height; if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`; const pixels = video.width * video.height; - if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`; + if (pixels < 640 * 640 || pixels > 3326 * 2494) return `${label} 总像素需要在 409600-8295044 之间`; } } if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒"; diff --git a/web/src/pages/video/index.tsx b/web/src/pages/video/index.tsx index f154100..7dad358 100644 --- a/web/src/pages/video/index.tsx +++ b/web/src/pages/video/index.tsx @@ -11,7 +11,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog"; import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatBytes, formatDuration } from "@/lib/image-utils"; -import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; +import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS, SEEDANCE_VIDEO_MIME_TYPES } from "@/lib/seedance-video"; import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video"; @@ -116,13 +116,13 @@ export default function VideoPage() { const addReferences = async (files?: FileList | null) => { const selectedFiles = Array.from(files || []); - const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file)); + const unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && !isSupportedAudioFile(file)); if (unsupported.length) message.warning("已忽略不支持的参考资产,请使用图片、mp4/mov 视频或 mp3/wav 音频"); const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length); - const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length); + const videoFiles = selectedFiles.filter((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length); const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length); if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图"); - if (selectedFiles.some((file) => file.type.startsWith("video/") && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 50MB 的参考视频"); + if (selectedFiles.some((file) => SEEDANCE_VIDEO_MIME_TYPES.includes(file.type) && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 200MB 的参考视频"); if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频"); const nextReferences = await Promise.all( imageFiles.map(async (file) => { diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 7d187b5..51bfc95 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -768,17 +768,6 @@ export async function requestGeneration(config: AiConfig, prompt: string, option } } -/** Seedream 系列模型和火山方舟 Ark 接口:图生图使用 /images/generations JSON 接口传 base64 参考图 */ -function isSeedreamRequestEdit(model: string, baseUrl: string) { - const modelLower = model.toLowerCase(); - const baseUrlLower = baseUrl.toLowerCase(); - return modelLower.includes("seedream") || modelLower.includes("doubao-seedream") || isArkApiUrl(baseUrlLower); -} - -function isArkApiUrl(baseUrlLower: string) { - return baseUrlLower.includes("ark.cn-beijing.volces.com"); -} - export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) { const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel); const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); @@ -813,18 +802,12 @@ export async function requestEdit(config: AiConfig, prompt: string, references: } } - // Seedream / 火山方舟 Ark 图生图:使用 /images/generations JSON 接口 - // 不支持 OpenAI 的 /images/edits multipart 接口 - if (isSeedreamRequestEdit(requestConfig.model, requestConfig.baseUrl)) { + if (requestConfig.apiFormat === "ark") { if (mask) throw new Error("蒙版编辑暂不支持该模型,请使用其他渠道"); const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); const background = normalizeBackground(config.background); - const refs = await Promise.all(references.map(async (image) => { - const dataUrl = await imageToDataUrl(image); - // 火山方舟 image 参数需要完整的 data URL 格式 - return dataUrl; - })); + const refs = await Promise.all(references.map((image) => imageToDataUrl(image))); try { const response = await axios.post( aiApiUrl(requestConfig, "/images/generations"), @@ -834,7 +817,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: n, response_format: "b64_json", output_format: IMAGE_OUTPUT_FORMAT, - image: refs, // Seedream 图生图:base64 参考图数组 + image: refs, ...(quality ? { quality } : {}), ...(requestSize ? { size: requestSize } : {}), ...(background ? { background } : {}), @@ -850,7 +833,6 @@ export async function requestEdit(config: AiConfig, prompt: string, references: } } - // 标准 OpenAI 图生图:/images/edits multipart/form-data const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); const background = normalizeBackground(config.background); diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 55a37d8..e21d6fd 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -180,7 +180,7 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin model: modelOptionName(model), content, ratio: normalizeSeedanceRatio(config.size), - resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)), + resolution: normalizeSeedanceResolution(config.vquality), duration: normalizeSeedanceDuration(config.videoSeconds), generate_audio: boolConfig(config.videoGenerateAudio, true), watermark: boolConfig(config.videoWatermark, false), diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 1376f1f..fc30cea 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -3,7 +3,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { nanoid } from "nanoid"; -export type ApiCallFormat = "openai" | "gemini"; +export type ApiCallFormat = "openai" | "gemini" | "ark"; export type ModelCapability = "image" | "video" | "text" | "audio"; export type ChannelModel = { @@ -62,6 +62,7 @@ export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; const CHANNEL_MODEL_SEPARATOR = "::"; const OPENAI_BASE_URL = "https://api.openai.com"; const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com"; +const ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; export const defaultConfig: AiConfig = { channelMode: "local", @@ -356,11 +357,13 @@ function normalizeChannels(config: AiConfig) { } export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) { - return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL; + if (apiFormat === "gemini") return GEMINI_BASE_URL; + if (apiFormat === "ark") return ARK_BASE_URL; + return OPENAI_BASE_URL; } function normalizeApiFormat(apiFormat: unknown): ApiCallFormat { - return apiFormat === "gemini" ? "gemini" : "openai"; + return apiFormat === "gemini" || apiFormat === "ark" ? apiFormat : "openai"; } function uniqueModelOptions(models: string[]) {