mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 04:44:28 +08:00
feat(api): add support for 火山方舟 protocol and enhance video reference validation
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 模型渠道新增火山方舟协议。
|
||||
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
|
||||
|
||||
## v0.10.0 - 2026-07-25
|
||||
|
||||
@@ -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 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }> = [
|
||||
|
||||
@@ -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 ? <div className="text-lg font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{seedanceResolutionOptions.map((item) => {
|
||||
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
|
||||
return (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
);
|
||||
})}
|
||||
{seedanceResolutionOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
{isSeedanceFastModel(model) ? <div className="text-[11px] leading-4 opacity-55">fast 模型不支持 1080p,会自动使用 720p。</div> : null}
|
||||
</SettingGroup>
|
||||
<SettingGroup title="比例" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
|
||||
@@ -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<AiConfig, "model" | "videoModel" | "baseUrl">) {
|
||||
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "apiFormat">) {
|
||||
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 秒";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<ImageApiResponse>(
|
||||
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in New Issue
Block a user