diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 6488dcd0a..7bf4ff3d6 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -158,7 +158,7 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, DefaultRatio: "16:9", - Resolutions: []string{"480p", "720p", "1080p", "2160p"}, + Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"}, DefaultResolution: "720p", GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, Watermark: VideoBooleanConfig{Supported: false, Default: false}, @@ -178,11 +178,15 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + video.Resolutions = []string{"480p", "720p", "1080p"} case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: video.References.MaxVideos, video.References.MaxAudios = 3, 3 video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 { + video.Resolutions = []string{"480p", "720p", "1080p"} + } case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} case model.ChannelInterfaceNovitaVideo: diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 61b2983ef..296620569 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -62,3 +62,23 @@ func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) { }) } } + +func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T) { + tests := map[string][]string{ + "newapi-channel-2": {"480p", "720p", "1080p", "1440p", "2160p"}, + "volcengine-ark-video": {"480p", "720p", "1080p"}, + "volcengine-jimeng-video": {"720p"}, + "gemini-veo": {"720p", "1080p"}, + } + for protocol, want := range tests { + t.Run(protocol, func(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(protocol, "") + if profile == nil || profile.Video == nil { + t.Fatalf("DefaultModelCapabilityConfigForModel(%q) video profile = nil", protocol) + } + if fmt.Sprint(profile.Video.Resolutions) != fmt.Sprint(want) { + t.Fatalf("resolutions = %v, want %v", profile.Video.Resolutions, want) + } + }) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 152743be2..f6b71360f 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -2025,16 +2025,22 @@ func normalizeNewAPIChannel2Resolution(value string, modelName string) string { if modelName == "grok-video-1.5-1080p" { return "1080p" } - switch strings.ToLower(strings.TrimSpace(value)) { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { case "480", "480p", "low": return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" - default: - return "720p" } + numeric := strings.TrimSuffix(normalized, "p") + if resolution, err := strconv.Atoi(numeric); err == nil && resolution > 0 { + return strconv.Itoa(resolution) + "p" + } + return "720p" } func runNewAPIChannel1VideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 6a54587bc..0ff88ef91 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -116,6 +116,22 @@ func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) { } } +func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) { + tests := map[string]string{ + "1440": "1440p", + "2k": "1440p", + "4K": "2160p", + "768p": "768p", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := normalizeNewAPIChannel2Resolution(input, "custom-video-model"); got != want { + t.Fatalf("normalizeNewAPIChannel2Resolution(%q) = %q, want %q", input, got, want) + } + }) + } +} + func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) { body, err := volcengineArkImageBody(canvasGenerationInput{ Prompt: "combine the references", diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index b7cf41c14..f19b7e5b0 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -160,6 +160,9 @@ func normalizeVideoResolution(value string) string { if strings.EqualFold(value, "4k") { return "2160p" } + if strings.EqualFold(value, "2k") { + return "1440p" + } if strings.HasSuffix(value, "p") { return value } @@ -180,6 +183,8 @@ func normalizeXAIVideoResolution(value string) string { return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" default: diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 2e3b6a581..0f5a8390e 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -145,14 +145,17 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 }, ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"], defaultRatio: "16:9", - resolutions: ["480p", "720p", "1080p", "2160p"], + resolutions: ["480p", "720p", "1080p", "1440p", "2160p"], defaultResolution: "720p", generateAudio: { supported: false, default: false }, watermark: { supported: false, default: false }, operations: ["text_to_video", "image_to_video"], defaultOperation: "text_to_video", }; - if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 }; + if (protocol === "volcengine-jimeng-video") { + video.duration = { selection: "enum", values: [5, 10], default: 5 }; + video.resolutions = ["720p"]; + } if (protocol === "gemini-veo") { video.duration = { selection: "enum", values: [4, 6, 8], default: 6 }; video.resolutions = ["720p", "1080p"]; @@ -166,6 +169,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " video.references.maxAudioDurationSeconds = 15; video.generateAudio = { supported: true, default: true }; } + if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1") video.resolutions = ["480p", "720p", "1080p"]; if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false }; if (protocol === "novita-video") { video.references.maxImages = 1; diff --git a/web/src/lib/video-generation-options.ts b/web/src/lib/video-generation-options.ts index 1d76d64f6..1c83f8622 100644 --- a/web/src/lib/video-generation-options.ts +++ b/web/src/lib/video-generation-options.ts @@ -1,5 +1,5 @@ export const VIDEO_DURATION_OPTIONS = [6, 9, 10, 15] as const; -export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 2160] as const; +export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 1440, 2160] as const; export const VIDEO_DURATION_MIN = 1; export function normalizeVideoDuration(value: string | number | undefined) { @@ -11,11 +11,8 @@ export function normalizeVideoResolution(value: string | number | undefined) { const token = String(value || "").trim().toLowerCase(); if (token === "low") return "480"; if (token === "auto" || token === "medium" || token === "high") return "720"; + if (token === "2k") return "1440"; if (token === "4k") return "2160"; - const resolution = Number(token.replace(/p$/i, "")) || 720; - return String(nearestOption(resolution, VIDEO_RESOLUTION_OPTIONS)); -} - -function nearestOption(value: number, options: readonly number[]) { - return options.reduce((nearest, option) => Math.abs(option - value) < Math.abs(nearest - value) ? option : nearest, options[0]); + const resolution = Number(token.replace(/p$/i, "")); + return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720"; } diff --git a/web/src/pages/create/creation-assets.ts b/web/src/pages/create/creation-assets.ts index 925ce3a2a..ccab33a50 100644 --- a/web/src/pages/create/creation-assets.ts +++ b/web/src/pages/create/creation-assets.ts @@ -4,9 +4,20 @@ import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; -export type CreationAttachment = (ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }; +export type CreationDocumentAttachment = { + id: string; + name: string; + type: string; + url: string; + storageKey: string; + bytes: number; + previewUrl: string; +}; +export type CreationAttachment = ((ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }) | CreationDocumentAttachment; export type CreationMode = "text" | "image" | "video"; -export type CreationAttachmentKind = "image" | "video" | "audio"; +export type CreationAttachmentKind = "image" | "video" | "audio" | "file"; + +const textDocumentExtensions = [".pdf", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"]; export type CreationAssetIdentity = { taskId?: string; @@ -15,17 +26,24 @@ export type CreationAssetIdentity = { }; export function creationUploadAccept(mode: CreationMode) { - return mode === "video" ? "image/*,video/*,audio/*" : "image/*"; + if (mode === "video") return "image/*,video/*,audio/*"; + if (mode === "text") return `image/*,video/*,audio/*,${textDocumentExtensions.join(",")}`; + return "image/*"; } -export function creationFileAccepted(mode: CreationMode, file: Pick) { - return file.type.startsWith("image/") || (mode === "video" && (file.type.startsWith("video/") || file.type.startsWith("audio/"))); +export function creationFileAccepted(mode: CreationMode, file: Pick) { + if (file.type.startsWith("image/")) return true; + if (mode === "video") return file.type.startsWith("video/") || file.type.startsWith("audio/"); + if (mode !== "text") return false; + const name = file.name.toLowerCase(); + return file.type.startsWith("video/") || file.type.startsWith("audio/") || file.type.startsWith("text/") || textDocumentExtensions.some((extension) => name.endsWith(extension)); } export function creationAttachmentKind(attachment: Pick): CreationAttachmentKind { if (attachment.type.startsWith("video/")) return "video"; if (attachment.type.startsWith("audio/")) return "audio"; - return "image"; + if (attachment.type.startsWith("image/")) return "image"; + return "file"; } export function splitCreationAttachments(attachments: CreationAttachment[]) { @@ -42,6 +60,16 @@ export function creationAttachmentPreview(attachment: CreationAttachment): { kin return { kind, url }; } +export function creationMediaAspectRatio(value: string | undefined, mode: CreationMode) { + const fallback = mode === "video" ? "16 / 9" : "1 / 1"; + const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/i); + if (!match) return fallback; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return fallback; + return `${width} / ${height}`; +} + export function removeCreationAttachment(attachments: T[], id: string) { return attachments.filter((attachment) => attachment.id !== id); } @@ -108,6 +136,18 @@ export function creationAttachmentFromAudio(file: File, uploaded: UploadedFile): }; } +export function creationAttachmentFromDocument(file: File, uploaded: UploadedFile): CreationAttachment { + return { + id: `upload:${file.name}:${uploaded.storageKey}`, + name: file.name, + type: uploaded.mimeType || file.type || "application/octet-stream", + url: uploaded.url, + storageKey: uploaded.storageKey, + bytes: uploaded.bytes, + previewUrl: "", + }; +} + export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment { const url = asset.data.dataUrl || asset.coverUrl; return { diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index 6b6e73860..a7e8aa506 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -52,7 +52,7 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen } if (reference.attachmentId) { const position = attachmentPositions.get(reference.attachmentId); - const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : "图片"; + const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : reference.kind === "text" ? "文件" : "图片"; mediaMappings.push(`- @${reference.label}:参考${kindLabel} ${position || 1}`); return; } @@ -70,11 +70,11 @@ export function creationReferenceMetadata(references: CreationReference[]) { function attachmentReference(attachment: CreationAttachment, index: number): CreationReference { const kind = creationAttachmentKind(attachment); - const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : "图片"; + const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "file" ? "文件" : "图片"; return { id: `upload:${attachment.id}`, nodeId: `upload:${attachment.id}`, - kind, + kind: kind === "file" ? "text" : kind, label: `${label}${index + 1}`, title: "当前参考内容", previewUrl: attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url), diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 2f8924d61..16c6a8406 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; -import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; +import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; +import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -8,31 +8,35 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; +import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; +import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; -import { requestImageQuestion } from "@/services/api/image"; +import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; -import { uploadMediaFile } from "@/services/file-storage"; +import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; -import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { useUserStore } from "@/stores/use-user-store"; import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; -import { creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; +import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; +type CreationViewMode = "chat" | "storyboard"; type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled"; type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string }; type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] }; @@ -81,6 +85,7 @@ const qualityOptions = [ ]; const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) })); const countOptions = ["1", "2", "3", "4"]; +const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024; const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }); const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); @@ -135,6 +140,7 @@ export default function CreatePage() { const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); + const [viewMode, setViewMode] = useState("chat"); const [selectedShotIndex, setSelectedShotIndex] = useState(-1); const [composingNextShot, setComposingNextShot] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); @@ -311,15 +317,15 @@ export default function CreatePage() { }; const libraryItems = useMemo(() => assets - .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video") + .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio") .map((asset) => ({ id: asset.id, title: asset.title, category: asset.category || "other", - kindLabel: asset.kind === "video" ? "视频" : "图片", + kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片", asset, searchText: asset.tags.join(" "), - disabledReason: mode !== "video" && asset.kind === "video" ? "视频仅支持视频创作" : undefined, + disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined, })), [assets, mode]); const uploadCreationAsset = async (file: File) => { if (file.type.startsWith("video/")) { @@ -329,6 +335,17 @@ export default function CreatePage() { attachment: creationAttachmentFromVideo(file, uploaded), }; } + if (file.type.startsWith("audio/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + return { + asset: creationAudioAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), + attachment: creationAttachmentFromAudio(file, uploaded), + }; + } + if (!file.type.startsWith("image/")) { + const uploaded = await uploadMediaFile(file, "create-upload"); + return { attachment: creationAttachmentFromDocument(file, uploaded) }; + } const uploaded = await uploadImage(file); return { asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), @@ -341,12 +358,12 @@ export default function CreatePage() { return; } const next = Array.from(files) - .filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))) + .filter((file) => creationFileAccepted(mode, file)) .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { const { asset, attachment } = await uploadCreationAsset(file); - addAsset(asset); + if (asset) addAsset(asset); return attachment; })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); @@ -357,13 +374,13 @@ export default function CreatePage() { }; const uploadLibraryAssets = async (files: FileList | File[]) => { - const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))); + const next = Array.from(files).filter((file) => creationFileAccepted(mode, file)); if (!next.length) return []; const settled = await Promise.allSettled(next.map(async (file) => { const { asset } = await uploadCreationAsset(file); - return addAsset(asset); + return asset ? addAsset(asset) : ""; })); - const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); + const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" && entry.value ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`); if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); @@ -378,7 +395,8 @@ export default function CreatePage() { const handleLibrarySelect = (selected: Asset[]) => { const next = selected.flatMap((asset): CreationAttachment[] => { if (asset.kind === "image") return [creationAttachmentFromAsset(asset)]; - if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)]; + if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)]; + if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)]; return []; }); if (!next.length) return; @@ -458,12 +476,12 @@ export default function CreatePage() { const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count }; try { if (mode === "text") { - const history = [...(activeConversation.messages || []), userMessage].map((item) => ({ + const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({ role: item.role, content: item.role === "user" - ? buildTextMessageContent(item) + ? await buildTextMessageContent(item) : item.content, - })); + }))); await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal }); } else if (mode === "image") { const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1))); @@ -702,7 +720,7 @@ export default function CreatePage() { count, setCount, composerFocusRef, - placeholderOverride: composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, + placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), onStop: () => abortRef.current?.abort(), }; @@ -731,7 +749,20 @@ export default function CreatePage() { onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }} /> - :
+ : viewMode === "chat" ?
+ setHistoryOpen(true)} /> +
+
{activeConversation.messages.map((item, index) => retryFailedMessage(item, index)} + onCreateVariant={() => createVariant(item, index)} + onCancel={() => void cancelPendingMessage(item)} + />)}
+
+
+
:
setHistoryOpen(true)} + viewMode={viewMode} + onViewModeChange={setViewMode} />
@@ -766,7 +799,7 @@ export default function CreatePage() { items={libraryItems} categoryLabels={creationAssetCategoryLabels} initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))} - upload={{ accept: mode === "video" ? "image/*,video/*" : "image/*", description: `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }} + upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }} onClose={() => setLibraryOpen(false)} onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))} /> @@ -820,6 +853,75 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec ; } +function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) { + return
+ + +
; +} + +function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) { + return
+ +
+ + +
+
; +} + +function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + if (item.role === "user") return ; + const mode = item.mode || "text"; + const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; + return
+
{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}{mode !== "text" ? {item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}
+ {mode === "text" ?
{item.content ? {item.content} : 正在生成…}
: } + {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null} +
; +} + +function CreationUserMessage({ item }: { item: CreationMessage }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const copyText = useCopyText(); + const visiblePrompt = displayCreationPrompt(item.content, item.references || []); + return
+
你{item.createdAt ? : null}
+

{visiblePrompt}

+ {item.references?.length ? : null} + {item.attachments?.length ?
{item.attachments.map((attachment) => { + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; + const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; + return ; + })}
: null} + setPreviewUrl("")} /> +
; +} + +function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const assets = useAssetStore((state) => state.assets); + const resultUrls = item.resultUrls || []; + const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; + const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas"; + if (item.status === "pending") return ; + if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; + if (!resultUrls.length) return
没有返回可预览结果
; + const isVideo = item.mode === "video"; + return
+ {isVideo ? :
{resultUrls.map((url) => )}
} +
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{resultAssetIds.length ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
+ setPreviewUrl("")} /> +
; +} + +function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) { + return
影策正在生成{mode === "video" ? "视频" : "图像"}{onCancel ? : null}
; +} + function CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; @@ -839,12 +941,13 @@ function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false onRemove: (id: string) => void; onAdd?: () => void; }) { - const isVideo = isVideoAttachment(item); - const url = isVideo ? item.url : item.previewUrl; + const kind = creationAttachmentKind(item); + const previewable = kind === "image" || kind === "video"; + const url = (kind === "video" ? item.url : item.previewUrl) || ""; return
- {primary && canAddMore && onAdd ? : null} @@ -891,6 +994,18 @@ function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); + const priceChannel = resolveModelChannel(props.config, props.model); + const credits = requestCreditCost({ + channelMode: priceChannel.scope === "system" ? "remote" : "local", + modelCosts: priceChannel.modelCosts, + model: modelOptionName(props.model), + count: props.mode === "image" ? props.count : 1, + seconds: props.mode === "video" ? props.seconds : 1, + }); + const showCost = creditsEnabled && credits !== null; + const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); + const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -908,7 +1023,7 @@ function CreationComposer(props: ComposerProps) { }; return
- + {primaryAttachment ? : }
@@ -922,12 +1037,29 @@ function CreationComposer(props: ComposerProps) { onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
- {props.busy ? : } + setPreviewUrl("")} />
; @@ -951,17 +1083,42 @@ function GenerationSettingsMenu(props: ComposerProps) { const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; - const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size); + const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : []; + const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; + const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); + const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; + const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : []; + const imageRatios = usesImageResolutionPicker + ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) + : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; + const selectImageRatio = (nextRatio: string) => { + if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") { + props.setRatio(nextRatio); + return; + } + props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio); + }; + const selectImageResolution = (choice: ImageResolutionChoice) => { + if (choice === "auto") { + props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio); + return; + } + const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; + if (nextSize) props.setRatio(nextSize); + }; const imageSummary = [ - ...(props.imageProfile.size.parameter !== "none" ? [props.ratio] : []), + ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; const panel =
- {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => )}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} + {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ?
{resolutions.map((option) => )}
: <> + {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} } @@ -1042,7 +1199,7 @@ function directorNoteFor(mode: CreationMode, settings: CreationSettings): string return ""; } -function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) { +function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) { const [railOpen, setRailOpen] = useState(false); const nextShotNumber = shots.length + 1; const closeRail = () => setRailOpen(false); @@ -1075,6 +1232,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
: null}
+ @@ -1114,9 +1272,8 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
{briefVisible && user ?
-
{shotScriptLabels[mode]}{user.createdAt ? : null}
+
{shotScriptLabels[mode]}{user.createdAt ? : null}
-

{visiblePrompt}

{user.references?.length ? : null} {user.attachments?.length ? : null} @@ -1164,9 +1321,10 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); return <>
{attachments.map((attachment) => { - const isVideo = isVideoAttachment(attachment); + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; - return ; + return ; })}
setPreviewUrl("")} />; } @@ -1222,19 +1380,50 @@ function conversationPreviewMessage(conversation: CreationConversation) { return fallback; } -function buildTextMessageContent(item: CreationMessage) { +async function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); - const images = (item.attachments || []).filter(isImageAttachment); - if (!images.length) return content; - return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))]; + const attachments = item.attachments || []; + if (!attachments.length) return content; + const parts: AiTextContentPart[] = [{ type: "text", text: content }]; + for (const attachment of attachments) { + if (isImageAttachment(attachment)) { + parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } }); + continue; + } + const url = await creationAttachmentDataUrl(attachment); + parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } }); + } + return parts; +} + +async function creationAttachmentDataUrl(attachment: CreationAttachment) { + if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + const attachmentUrl = attachment.url || ""; + if (attachmentUrl.startsWith("data:")) return attachmentUrl; + const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null; + if (blob) { + if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + return blobToDataUrl(blob); + } + if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl; + throw new Error(`${attachment.name} 无法读取,请重新上传后再试`); +} + +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("附件读取失败")); + reader.readAsDataURL(blob); + }); } function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { - return attachment.type.startsWith("video/"); + return creationAttachmentKind(attachment) === "video"; } function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { - return !isVideoAttachment(attachment); + return creationAttachmentKind(attachment) === "image"; } type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 6fbdbbbe1..df4eaed3c 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -14,9 +14,14 @@ import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type I export type AiTextMessage = { role: "system" | "user" | "assistant"; - content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; + content: string | AiTextContentPart[]; }; +export type AiTextContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + | { type: "file_url"; file_url: { url: string; name: string; mimeType: string } }; + export type ResponseToolCall = { id: string; type: "function"; @@ -43,7 +48,7 @@ export type ToolResponseResult = { type ToolChoice = "auto" | "required" | { type: "function"; name: string }; type ResponseMessageContent = AiTextMessage["content"] | string; -type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; +type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string }; type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string }; type ResponseApiToolDefinition = { type: "function"; @@ -347,7 +352,13 @@ function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[] function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? { type: "input_text" as const, text: item.text } : { type: "input_image" as const, image_url: item.image_url.url })); + return content.map((item) => { + if (item.type === "text") return { type: "input_text" as const, text: item.text }; + if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url }; + return item.file_url.url.startsWith("data:") + ? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url } + : { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url }; + }); } function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition { @@ -377,13 +388,24 @@ function toChatCompletionMessages(messages: ResponseInputMessage[]) { if (message.role === "tool") { result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content }); } else { - result.push({ role: message.role, content: message.content }); + result.push({ role: message.role, content: toChatCompletionContent(message.content) }); } index += 1; } return result; } +function toChatCompletionContent(content: ResponseMessageContent) { + if (!Array.isArray(content)) return content; + return content.map((item) => { + if (item.type !== "file_url") return item; + const file = item.file_url.url.startsWith("data:") + ? { filename: item.file_url.name, file_data: item.file_url.url } + : { filename: item.file_url.name, file_url: item.file_url.url }; + return { type: "file", file }; + }); +} + function toChatCompletionToolChoice(toolChoice: ToolChoice) { return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice; } @@ -653,18 +675,22 @@ function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] { function toGeminiParts(content: ResponseMessageContent): GeminiPart[] { if (!Array.isArray(content)) return [{ text: String(content || "") }]; - return content.map((item) => (item.type === "text" ? { text: item.text } : toGeminiImagePart(item.image_url.url))); + return content.map((item) => { + if (item.type === "text") return { text: item.text }; + if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png"); + return toGeminiFilePart(item.file_url.url, item.file_url.mimeType); + }); } -function toGeminiImagePart(url: string): GeminiPart { +function toGeminiFilePart(url: string, mimeType: string): GeminiPart { const match = url.match(/^data:([^;,]+);base64,(.+)$/); if (match) return { inlineData: { mimeType: match[1], data: match[2] } }; - return { fileData: { fileUri: url, mimeType: "image/png" } }; + return { fileData: { fileUri: url, mimeType } }; } function geminiTextContent(content: ResponseMessageContent) { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? item.text : item.image_url.url)).join("\n"); + return content.map((item) => item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`).join("\n"); } function jsonObject(value: string): Record { @@ -782,7 +808,7 @@ async function requestGeminiImages(config: AiConfig, prompt: string, references: async function requestGeminiImagesOnce(config: AiConfig, prompt: string, references: ReferenceImage[], options?: RequestOptions) { const parts: GeminiPart[] = [{ text: prompt }]; for (const image of references) { - parts.push(toGeminiImagePart(await imageToDataUrl(image))); + parts.push(toGeminiFilePart(await imageToDataUrl(image), image.type || "image/png")); } const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config)); const response = await axios.post( diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 4d5186915..e792a8a79 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -623,6 +623,7 @@ function normalizeVideoSize(value: string) { function normalizeVideoResolution(value: string) { if (value === "low") return "480p"; if (value === "auto" || value === "high" || value === "medium") return "720p"; + if (value.toLowerCase() === "2k") return "1440p"; if (value.toLowerCase() === "4k") return "2160p"; const resolution = value.replace(/p$/i, "") || "720"; return `${resolution}p`; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 3b030d20f..72aec7a27 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -765,6 +765,7 @@ --prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */ --settings-model-option-min-height: 76px; --prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */ + --creation-media-pending-max-width: 320px; /* 进度条(对应 #98 决策3 任务进度)*/ --progress-bar-height: 6px; @@ -3183,23 +3184,6 @@ html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { border-color: rgba(20, 26, 35, .2) !important; background: #e9edf1 !important; } - .creation-chat-submit { - display: grid; - width: 38px; - height: 38px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 50%; - background: #303540; - color: #f1f3f5; - transition: background-color 140ms ease, color 140ms ease, transform 140ms var(--motion-ease-out); - } - .creation-chat-submit:hover:not(:disabled) { background: #f3f4f6; color: #121417; transform: translateY(-1px); } - .creation-chat-submit:active:not(:disabled) { transform: scale(.96); } - .creation-chat-submit:disabled { cursor: not-allowed; background: #282c33; color: #5c6370; } - .creation-chat-submit.is-stopping { background: #bd4949; color: white; } - .creation-control-popover-surface { border: 0 !important; border-radius: 12px !important; @@ -3299,7 +3283,6 @@ .creation-chat-controls { max-width: calc(100% - 42px); gap: 5px; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 34px !important; padding: 0 8px !important; font-size: 11px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(152px, 48vw) !important; } - .creation-chat-submit { width: 36px; height: 36px; } .creation-thread-stage { padding-top: 28px; } .creation-assistant-message { margin-bottom: 32px; } .creation-message-content, .creation-message-error { padding-left: 0; } @@ -6714,6 +6697,7 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba .creation-chat-controls { display: flex; min-width: 0; + flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 2px; @@ -6763,26 +6747,6 @@ html:not(.dark) .creation-chat-control.is-mode, html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; } -.creation-chat-submit { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 9px; - background: #f3f4f5; - color: #1b1d21; - cursor: pointer; - transition: transform 140ms var(--motion-ease-out), background-color 140ms ease; -} - -.creation-chat-submit:hover:not(:disabled) { background: #fff; transform: translateY(-1px); } -.creation-chat-submit:disabled { background: #292d34; color: #606771; cursor: not-allowed; } -.creation-chat-submit.is-stopping { background: #d06060; color: #fff; } -html:not(.dark) .creation-chat-submit { background: #202328; color: #fff; } -html:not(.dark) .creation-chat-submit:hover:not(:disabled) { background: #2d3139; } - .creation-control-popover-surface, .creation-model-picker-surface { overflow: hidden; @@ -6892,7 +6856,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f .creation-empty-workspace { width: calc(100% - 28px); padding-bottom: 8vh; } .creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; } .creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; } - .creation-chat-controls { max-width: calc(100% - 38px); } + .creation-chat-controls { max-width: none; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; } .creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); } @@ -6901,8 +6865,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f @media (prefers-reduced-motion: reduce) { .creation-empty-composer, - .creation-chat-reference, - .creation-chat-submit { animation: none; transition: none; } + .creation-chat-reference { animation: none; transition: none; } } /* 创作入口与空间导航在同一块画布上,避免视觉断层。 */ @@ -10373,3 +10336,322 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou vertical-align: -0.08em; accent-color: var(--primary); } + +/* ===== 创作页 · 连续对话与媒体状态 ===== */ +.creation-home .creation-thread-workbench { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.creation-home .creation-thread-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); +} + +.creation-home .creation-thread-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.creation-home .creation-view-switch { + display: inline-flex; + align-items: center; + gap: var(--space-1); + border-radius: var(--r-md); + background: var(--creation-surface-muted); + padding: var(--space-1); +} + +.creation-home .creation-view-switch button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-caption); + cursor: pointer; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease; +} + +.creation-home .creation-view-switch button[aria-pressed="true"] { + background: var(--creation-surface-selected); + color: var(--creation-text); +} + +.creation-home .creation-view-switch button:focus-visible { + outline: 2px solid var(--creation-accent); + outline-offset: 1px; +} + +.creation-home .creation-view-switch svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .storyboard-workbench-bar-actions .creation-view-switch { margin-right: var(--space-2); } + +.creation-home .creation-thread-stage { + width: min(900px, calc(100% - 56px)); + min-height: 100%; + margin: 0 auto; + padding: var(--space-8) 0 var(--space-6); +} + +.creation-home .creation-thread-composer { + position: relative; + z-index: 20; + bottom: auto; + width: min(900px, calc(100% - 56px)); + margin: 0 auto; + padding: var(--space-2) 0 var(--space-5); + background: var(--creation-bg); +} + +.creation-home .creation-user-message { + display: flex; + width: min(680px, 86%); + max-width: none; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); + margin: 0 0 var(--space-10) auto; + border-radius: 0; + background: transparent; + padding: 0; + color: var(--creation-text); + white-space: normal; +} + +html:not(.dark) .creation-home .creation-user-message { background: transparent; } + +.creation-home .creation-user-message-meta { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--creation-faint); + font-size: var(--fs-label); +} + +.creation-home .creation-user-message-meta time, +.creation-home .creation-message-heading time { color: var(--creation-faint); font-size: var(--fs-micro); } + +.creation-home .creation-user-message-copy { + display: grid; + width: var(--space-5); + height: var(--space-5); + flex: 0 0 var(--space-5); + place-items: center; + border: 0; + border-radius: var(--r-xs); + background: transparent; + color: var(--creation-faint); + padding: 0; + cursor: pointer; + opacity: .66; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease, opacity var(--motion-state) ease; +} + +.creation-home .creation-user-message-copy:hover { + background: var(--creation-surface-hover); + color: var(--creation-text); + opacity: 1; +} + +.creation-home .creation-user-message-copy:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: 1px; } +.creation-home .creation-user-message-copy svg { width: var(--space-3); height: var(--space-3); } + +.creation-home .creation-user-message-copy-wrap { + max-width: 100%; +} + +.creation-home .creation-user-message-copy-wrap p { + margin: 0; + color: var(--creation-text); + font-size: var(--fs-body); + line-height: 1.75; + text-align: right; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.creation-home .creation-assistant-message { + width: min(760px, 100%); + max-width: none; + margin: 0 0 var(--space-10); + background: transparent; +} + +.creation-home .creation-message-heading { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.creation-home .creation-message-heading strong { color: var(--creation-text); font-size: var(--fs-body); font-weight: 620; } +.creation-home .creation-message-progress-copy { color: var(--creation-muted); font-size: var(--fs-body); } + +.creation-home .creation-message-content { + max-width: 720px; + padding: var(--space-3) 0 0 var(--space-8); +} + +.creation-home .creation-media-pending { + position: relative; + display: grid; + width: min(var(--creation-media-pending-max-width), calc(100% - var(--space-8))); + min-height: 0; + isolation: isolate; + place-items: center; + overflow: hidden; + margin: var(--space-3) 0 0 var(--space-8); + border: 0; + border-radius: var(--r-lg); + background: linear-gradient(115deg, color-mix(in srgb, var(--status-loading) 16%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 7%, var(--creation-surface)), color-mix(in srgb, var(--status-loading) 28%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 10%, var(--creation-surface))); + background-size: 280% 280%; + padding: 0; + animation: creation-media-breathe 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient(105deg, transparent 12%, color-mix(in srgb, var(--status-loading) 24%, transparent) 48%, color-mix(in srgb, var(--creation-text) 12%, transparent) 58%, transparent 88%); + content: ""; + opacity: .38; + transform: translateX(-38%); + animation: creation-media-breathe-layer 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon { + display: grid; + width: var(--space-12); + height: var(--space-12); + place-items: center; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--creation-surface) 72%, transparent); + color: var(--creation-text); + animation: creation-media-icon-breathe 1.8s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon svg { width: var(--space-5); height: var(--space-5); } + +.creation-home .creation-media-pending > button { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + min-height: var(--space-8); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--creation-surface) 78%, transparent); + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-label); + cursor: pointer; +} + +.creation-home .creation-media-result { max-width: 680px; padding: var(--space-3) 0 0 var(--space-8); } +.creation-home .creation-image-result-grid { display: flex; max-width: 680px; flex-wrap: wrap; gap: var(--space-2); } +.creation-home .creation-image-result { width: min(320px, 100%); aspect-ratio: auto; background: var(--creation-surface-muted); } +.creation-home .creation-image-result img { display: block; width: 100%; height: auto; max-height: 360px; object-fit: contain; } +.creation-home .creation-video-result { width: min(680px, 100%); } +.creation-home .creation-video-result video { display: block; width: 100%; height: 100%; object-fit: contain; } + +.creation-home .creation-media-error { + display: flex; + width: min(680px, calc(100% - var(--space-8))); + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: var(--space-3) 0 0 var(--space-8); + color: var(--status-error); + font-size: var(--fs-caption); +} + +.creation-home .creation-media-error button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--status-error) 10%, transparent); + color: inherit; + padding: 0 var(--space-3); + cursor: pointer; +} + +.creation-home .creation-media-error svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .creation-chat-attachment-preview.is-file { + width: min(220px, 100%); + cursor: default; +} + +.creation-home .creation-chat-file-icon { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-2); + color: var(--creation-muted); + padding: 0 var(--space-2); +} + +.creation-home .creation-chat-file-icon > svg { width: var(--space-4); height: var(--space-4); flex: 0 0 auto; } +.creation-home .creation-chat-file-icon em { overflow: hidden; font-size: var(--fs-label); font-style: normal; text-overflow: ellipsis; white-space: nowrap; } + +.creation-home .creation-user-message-attachments button.is-file { + display: grid; + place-items: center; + background: var(--creation-surface-muted); + color: var(--creation-muted); + cursor: default; +} + +.creation-home .creation-user-message-attachments button.is-file > svg { width: var(--space-4); height: var(--space-4); } + +@keyframes creation-media-breathe { + 0%, 100% { background-position: 0% 50%; filter: brightness(.9) saturate(.82); } + 50% { background-position: 100% 50%; filter: brightness(1.12) saturate(1.18); } +} + +@keyframes creation-media-breathe-layer { + 0%, 100% { opacity: .24; transform: translateX(-38%) scale(1); } + 50% { opacity: .72; transform: translateX(38%) scale(1.04); } +} + +@keyframes creation-media-icon-breathe { + 0%, 100% { opacity: .58; transform: scale(.94); } + 50% { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .creation-home .creation-media-pending, + .creation-home .creation-media-pending::before, + .creation-home .creation-media-pending-icon { animation: none; } +} + +@media (max-width: 800px) { + .creation-home .creation-thread-toolbar { align-items: flex-end; padding: var(--space-3); } + .creation-home .creation-view-switch button { padding: 0 var(--space-2); } + .creation-home .creation-thread-stage, + .creation-home .creation-thread-composer { width: calc(100% - 28px); } + .creation-home .creation-thread-stage { padding-top: var(--space-5); } + .creation-home .creation-user-message { width: 92%; max-width: none; } + .creation-home .creation-message-content, + .creation-home .creation-media-result { padding-left: 0; } + .creation-home .creation-media-pending { width: min(var(--creation-media-pending-max-width), 100%); margin-left: 0; } + .creation-home .creation-media-error { width: 100%; margin-left: 0; } +} diff --git a/web/test/creation-references.test.ts b/web/test/creation-references.test.ts index 147ef30fb..a5ae1ab08 100644 --- a/web/test/creation-references.test.ts +++ b/web/test/creation-references.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references"; -import type { CreationAttachment } from "../src/pages/create/creation-assets"; +import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets"; import { buildCreationMentionReferences, reconcileCreationAttachmentLimit, removeCreationReferenceTokens } from "../src/pages/create/creation-references"; function imageAttachment(id: string): CreationAttachment { @@ -36,4 +36,28 @@ describe("creation references", () => { expect(result.attachments).toBe(attachments); expect(result.removedReferences).toEqual([]); }); + + test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => { + expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true); + expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true); + expect(creationFileAccepted("image", { name: "story.pdf", type: "application/pdf" })).toBe(false); + expect(creationUploadAccept("text")).toContain(".docx"); + }); + + test("文档附件会作为文本资源参与引用", () => { + const attachment: CreationAttachment = { id: "document", name: "script.pdf", type: "application/pdf", url: "https://example.com/script.pdf", storageKey: "resource:document", bytes: 1024, previewUrl: "" }; + const [reference] = buildCreationMentionReferences([], [attachment]); + + expect(creationAttachmentKind(attachment)).toBe("file"); + expect(reference.kind).toBe("text"); + expect(reference.label).toBe("文件1"); + }); + + test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => { + expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1"); + expect(creationMediaAspectRatio("1920x1080", "image")).toBe("1920 / 1080"); + expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1"); + }); }); diff --git a/web/test/video-generation-options.test.ts b/web/test/video-generation-options.test.ts new file mode 100644 index 000000000..6e7d39aac --- /dev/null +++ b/web/test/video-generation-options.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; + +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { normalizeVideoResolution, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options"; + +describe("video generation resolution options", () => { + test("统一档位包含 1440P 与 4K,并识别常见别名", () => { + expect(VIDEO_RESOLUTION_OPTIONS).toEqual([480, 720, 1080, 1440, 2160]); + expect(normalizeVideoResolution("2k")).toBe("1440"); + expect(normalizeVideoResolution("1440p")).toBe("1440"); + expect(normalizeVideoResolution("4K")).toBe("2160"); + }); + + test("保留模型声明的非标准档位而不是静默降级", () => { + expect(normalizeVideoResolution("768p")).toBe("768"); + }); + + test("按协议限制实际可选档位", () => { + expect(defaultModelCapabilityConfig("newapi-channel-2").video?.resolutions).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]); + expect(defaultModelCapabilityConfig("volcengine-ark-video").video?.resolutions).toEqual(["480p", "720p", "1080p"]); + expect(defaultModelCapabilityConfig("volcengine-jimeng-video").video?.resolutions).toEqual(["720p"]); + expect(defaultModelCapabilityConfig("gemini-veo").video?.resolutions).toEqual(["720p", "1080p"]); + }); +});