diff --git a/docs/features.md b/docs/features.md index 5b7a1ca..a6963d0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -82,11 +82,11 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不 - 图片比例。 - 生成数量。 - 视频模型。 -- 视频比例、清晰度和时长。 +- 视频比例、清晰度、时长、生成声音和水印。 普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。 -视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。Seedance 参考视频需要公网可访问 URL;本地上传视频会先通过 `/api/v1/media/references` 保存到服务端,再由 `PUBLIC_BASE_URL` 生成可供火山服务器拉取的公开链接。 +视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。Seedance 参考视频和参考音频需要公网可访问 URL;本地上传素材会先通过 `/api/v1/media/references` 保存到服务端,再由 `PUBLIC_BASE_URL` 生成可供火山服务器拉取的公开链接。 ## 画布助手 diff --git a/docs/pending-test.md b/docs/pending-test.md index 3b1a416..e313d2b 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -22,5 +22,9 @@ - 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。 - 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。 - 火山方舟 Agent Plan / Seedance 2.0 视频生成需要在真实账号下验证:`/contents/generations/tasks` 创建任务、轮询状态、`content.video_url` 回填画布,以及 401/403/429/超时错误提示。 -- `PUBLIC_BASE_URL` 已配置公网域名时,需要验证本地上传参考视频能被火山拉取;未配置或配置为内网地址时,需要验证前端能给出明确提示。 +- 管理后台保存私有渠道后,需要验证所有已启用渠道里的模型会自动出现在公开 `availableModels`,并且 `defaultVideoModel`、`defaultImageModel`、`defaultTextModel` 在为空或失效时会自动修复,前台不再显示旧的 `grok` 默认值。 +- `/video` 和画布视频设置已按 Seedance 2.0 增加分辨率、比例、4-15 秒/智能时长、生成声音和水印参数;需要在真实浏览器里验证参数回填、生成记录和画布节点配置都能保持一致。 +- `/video` 支持最多 9 张参考图、3 个参考视频、3 个参考音频;需要验证格式、大小、音频时长提示和生成请求中的 `reference_image`、`reference_video`、`reference_audio` 组装。 +- 画布新增音频节点,支持上传、拖入、播放、移动、缩放、删除,并可作为上游参考音频参与 Seedance 视频生成;需要验证刷新后本地音频 URL 能恢复。 +- `PUBLIC_BASE_URL` 已配置公网域名时,需要验证本地上传参考视频和参考音频能被火山拉取;未配置或配置为内网地址时,需要验证前端能给出明确提示。 - Seedance 返回远程视频 URL 但浏览器无法下载为 Blob 时,需要验证视频节点刷新后仍保留远程 URL,并确认上游 URL 有效期限制。 diff --git a/docs/system-settings.md b/docs/system-settings.md index 74a267b..0996665 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -41,11 +41,12 @@ | 字段 | 类型 | 说明 | | --- | --- | --- | -| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 | +| `availableModels` | string[] | 系统可用模型;保存设置时会自动合并所有已启用私有渠道的模型 | | `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 | -| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 | -| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 | -| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 | +| `defaultModel` | string | 默认模型,从 `availableModels` 中选择;为空或失效时优先选择文本模型 | +| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedream`、`image`、`gpt-image` 模型 | +| `defaultVideoModel` | string | 默认视频模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedance`、`video` 模型 | +| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择;为空或失效时优先选择非图片/视频模型 | | `systemPrompt` | string | 系统提示词 | | `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 | diff --git a/handler/media_reference.go b/handler/media_reference.go index d080f97..b528588 100644 --- a/handler/media_reference.go +++ b/handler/media_reference.go @@ -12,7 +12,16 @@ import ( "github.com/google/uuid" ) -const referenceMediaMaxBytes = 80 << 20 +const ( + referenceMediaMaxBytes = 80 << 20 + referenceImageMaxBytes = 30 << 20 + referenceVideoMaxBytes = 50 << 20 + referenceAudioMaxBytes = 15 << 20 + referenceImageAllowedText = "jpeg/png/webp/bmp/gif/heic/heif 图片" + referenceVideoAllowedText = "mp4/mov 视频" + referenceAudioAllowedText = "mp3/wav 音频" + referenceMediaAllowedText = referenceImageAllowedText + "、" + referenceVideoAllowedText + "或" + referenceAudioAllowedText +) type referenceMediaUploadResult struct { ID string `json:"id"` @@ -24,7 +33,7 @@ type referenceMediaUploadResult struct { func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) { publicBaseURL := strings.TrimRight(strings.TrimSpace(config.Cfg.PublicBaseURL), "/") if publicBaseURL == "" { - Fail(w, "未配置 PUBLIC_BASE_URL,无法把本地参考视频提供给火山方舟访问") + Fail(w, "未配置 PUBLIC_BASE_URL,无法把本地参考素材提供给火山方舟访问") return } r.Body = http.MaxBytesReader(w, r.Body, referenceMediaMaxBytes+1) @@ -44,7 +53,7 @@ func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) { mimeType, ext, ok := normalizeReferenceMediaType(header.Header.Get("Content-Type"), filepath.Ext(header.Filename)) if !ok { - Fail(w, "参考素材格式不支持,请使用 jpeg/png/webp/bmp/gif/heic/heif 图片或 mp4/mov 视频") + Fail(w, "参考素材格式不支持,请使用 "+referenceMediaAllowedText) return } if err := os.MkdirAll(referenceMediaDir(), 0o755); err != nil { @@ -65,9 +74,14 @@ func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) { Fail(w, "参考素材保存失败") return } - if bytes <= 0 || bytes > referenceMediaMaxBytes { + if bytes <= 0 { _ = os.Remove(targetPath) - Fail(w, "参考素材为空或超过大小限制") + Fail(w, "参考素材为空") + return + } + if limit := referenceMediaTypeMaxBytes(mimeType); limit > 0 && bytes > limit { + _ = os.Remove(targetPath) + Fail(w, referenceMediaSizeMessage(mimeType)) return } OK(w, referenceMediaUploadResult{ @@ -141,6 +155,10 @@ func referenceMediaExtByMimeType(mimeType string) string { return ".mp4" case "video/quicktime", "video/mov": return ".mov" + case "audio/mpeg", "audio/mp3": + return ".mp3" + case "audio/wav", "audio/x-wav", "audio/wave": + return ".wav" default: return "" } @@ -166,7 +184,37 @@ func mimeTypeByReferenceMediaExt(ext string) string { return "video/mp4" case ".mov": return "video/quicktime" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" default: return "" } } + +func referenceMediaTypeMaxBytes(mimeType string) int64 { + if strings.HasPrefix(mimeType, "image/") { + return referenceImageMaxBytes + } + if strings.HasPrefix(mimeType, "video/") { + return referenceVideoMaxBytes + } + if strings.HasPrefix(mimeType, "audio/") { + return referenceAudioMaxBytes + } + return referenceMediaMaxBytes +} + +func referenceMediaSizeMessage(mimeType string) string { + if strings.HasPrefix(mimeType, "image/") { + return "参考图片超过大小限制,请使用 30MB 以内的图片" + } + if strings.HasPrefix(mimeType, "video/") { + return "参考视频超过大小限制,请使用 50MB 以内的 mp4/mov 视频" + } + if strings.HasPrefix(mimeType, "audio/") { + return "参考音频超过大小限制,请使用 15MB 以内的 mp3/wav 音频" + } + return "参考素材超过大小限制" +} diff --git a/handler/media_reference_test.go b/handler/media_reference_test.go new file mode 100644 index 0000000..95cd7fe --- /dev/null +++ b/handler/media_reference_test.go @@ -0,0 +1,40 @@ +package handler + +import "testing" + +func TestNormalizeReferenceMediaTypeSupportsAudio(t *testing.T) { + tests := []struct { + name string + contentType string + ext string + wantMime string + wantExt string + }{ + {name: "mp3 mime", contentType: "audio/mpeg", ext: ".bin", wantMime: "audio/mpeg", wantExt: ".mp3"}, + {name: "wav ext fallback", contentType: "application/octet-stream", ext: ".wav", wantMime: "audio/wav", wantExt: ".wav"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mimeType, ext, ok := normalizeReferenceMediaType(tt.contentType, tt.ext) + if !ok { + t.Fatal("expected media type to be accepted") + } + if mimeType != tt.wantMime || ext != tt.wantExt { + t.Fatalf("got (%q, %q), want (%q, %q)", mimeType, ext, tt.wantMime, tt.wantExt) + } + }) + } +} + +func TestReferenceMediaTypeMaxBytes(t *testing.T) { + if got := referenceMediaTypeMaxBytes("audio/mpeg"); got != referenceAudioMaxBytes { + t.Fatalf("audio max bytes = %d, want %d", got, referenceAudioMaxBytes) + } + if got := referenceMediaTypeMaxBytes("video/mp4"); got != referenceVideoMaxBytes { + t.Fatalf("video max bytes = %d, want %d", got, referenceVideoMaxBytes) + } + if got := referenceMediaTypeMaxBytes("image/png"); got != referenceImageMaxBytes { + t.Fatalf("image max bytes = %d, want %d", got, referenceImageMaxBytes) + } +} diff --git a/service/settings.go b/service/settings.go index 51ac403..121c802 100644 --- a/service/settings.go +++ b/service/settings.go @@ -20,7 +20,7 @@ var adminModelHTTPClient = &http.Client{Timeout: 30 * time.Second} func PublicSettings() (model.PublicSetting, error) { settings, err := repository.GetSettings() - return normalizePublicSetting(settings.Public), err + return normalizeSettings(settings).Public, err } func AdminSettings() (model.Settings, error) { @@ -63,12 +63,16 @@ func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName str } func normalizeSettings(settings model.Settings) model.Settings { - settings.Public = normalizePublicSetting(settings.Public) settings.Private = normalizePrivateSetting(settings.Private) + settings.Public = normalizePublicSettingWithChannels(settings.Public, settings.Private.Channels) return settings } func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { + return normalizePublicSettingWithChannels(setting, nil) +} + +func normalizePublicSettingWithChannels(setting model.PublicSetting, channels []model.ModelChannel) model.PublicSetting { if setting.ModelChannel.AvailableModels == nil { setting.ModelChannel.AvailableModels = []string{} } @@ -89,6 +93,16 @@ func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { enabled := true setting.Auth.AllowRegister = &enabled } + enabledModels := enabledChannelModels(channels) + if len(enabledModels) > 0 { + setting.ModelChannel.AvailableModels = enabledModels + } else { + setting.ModelChannel.AvailableModels = uniqueModelNames(setting.ModelChannel.AvailableModels) + } + setting.ModelChannel.DefaultTextModel = repairDefaultModel(setting.ModelChannel.DefaultTextModel, setting.ModelChannel.AvailableModels, isTextModelName) + setting.ModelChannel.DefaultImageModel = repairDefaultModel(setting.ModelChannel.DefaultImageModel, setting.ModelChannel.AvailableModels, isImageModelName) + setting.ModelChannel.DefaultVideoModel = repairDefaultModel(setting.ModelChannel.DefaultVideoModel, setting.ModelChannel.AvailableModels, isVideoModelName) + setting.ModelChannel.DefaultModel = repairDefaultModel(setting.ModelChannel.DefaultModel, setting.ModelChannel.AvailableModels, isTextModelName) return setting } @@ -224,6 +238,63 @@ func isSeedanceModelName(modelName string) bool { return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance") } +func enabledChannelModels(channels []model.ModelChannel) []string { + models := []string{} + for _, channel := range channels { + if !channel.Enabled { + continue + } + models = append(models, channel.Models...) + } + return uniqueModelNames(models) +} + +func uniqueModelNames(models []string) []string { + result := []string{} + seen := map[string]bool{} + for _, item := range models { + name := strings.TrimSpace(item) + if name == "" || seen[name] { + continue + } + seen[name] = true + result = append(result, name) + } + return result +} + +func repairDefaultModel(current string, models []string, preferred func(string) bool) string { + current = strings.TrimSpace(current) + for _, item := range models { + if item == current { + return current + } + } + for _, item := range models { + if preferred(item) { + return item + } + } + if len(models) > 0 { + return models[0] + } + return "" +} + +func isVideoModelName(modelName string) bool { + name := strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(name, "seedance") || strings.Contains(name, "video") +} + +func isImageModelName(modelName string) bool { + name := strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(name, "seedream") || strings.Contains(name, "gpt-image") || strings.Contains(name, "image") +} + +func isTextModelName(modelName string) bool { + return !isImageModelName(modelName) && !isVideoModelName(modelName) +} + func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel { if channel.Protocol == "" { channel.Protocol = "openai" diff --git a/service/settings_test.go b/service/settings_test.go index ea92d83..27c6b2e 100644 --- a/service/settings_test.go +++ b/service/settings_test.go @@ -60,3 +60,41 @@ func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) { t.Fatalf("BuildModelChannelURL = %q, want %q", got, want) } } + +func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) { + settings := normalizeSettings(model.Settings{ + Public: model.PublicSetting{ + ModelChannel: model.PublicModelChannelSetting{ + AvailableModels: []string{"grok-imagine-video", "disabled-model"}, + DefaultModel: "grok-imagine-video", + DefaultTextModel: "missing-text", + DefaultImageModel: "missing-image", + DefaultVideoModel: "missing-video", + }, + }, + Private: model.PrivateSetting{ + Channels: []model.ModelChannel{ + {Enabled: true, Models: []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast", "gpt-5.5"}}, + {Enabled: false, Models: []string{"disabled-model"}}, + }, + }, + }) + + channel := settings.Public.ModelChannel + wantModels := []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast"} + if !reflect.DeepEqual(channel.AvailableModels, wantModels) { + t.Fatalf("available models = %#v, want %#v", channel.AvailableModels, wantModels) + } + if channel.DefaultModel != "gpt-5.5" { + t.Fatalf("default model = %q, want text model", channel.DefaultModel) + } + if channel.DefaultTextModel != "gpt-5.5" { + t.Fatalf("default text model = %q, want text model", channel.DefaultTextModel) + } + if channel.DefaultImageModel != "doubao-seedream-5.0-lite" { + t.Fatalf("default image model = %q, want seedream", channel.DefaultImageModel) + } + if channel.DefaultVideoModel != "doubao-seedance-2.0-fast" { + t.Fatalf("default video model = %q, want seedance", channel.DefaultVideoModel) + } +} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index c262680..7b5029f 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -341,7 +341,7 @@ export default function AdminSettingsPage() { const nextChannelModels = collectChannelModels(nextChannels); const nextSettings = normalizeSettings({ ...values, - public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } }, + public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: nextChannelModels } }, private: { ...values.private, channels: nextChannels }, }); const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); @@ -414,7 +414,7 @@ export default function AdminSettingsPage() {