diff --git a/docs/pending-test.md b/docs/pending-test.md
index e313d2b..24a039d 100644
--- a/docs/pending-test.md
+++ b/docs/pending-test.md
@@ -1,5 +1,6 @@
# 待测试
+- Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
diff --git a/handler/ai.go b/handler/ai.go
index 045936c..c569c0a 100644
--- a/handler/ai.go
+++ b/handler/ai.go
@@ -119,12 +119,12 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
- _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
+ body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
log.Printf("AI upstream error: url=%s status=%d", request.URL.String(), response.StatusCode)
if onFailure != nil {
onFailure()
}
- Fail(w, aiStatusMessage(response.StatusCode))
+ Fail(w, aiUpstreamStatusMessage(response.StatusCode, body))
return
}
@@ -239,6 +239,54 @@ func aiStatusMessage(statusCode int) string {
}
}
+func aiUpstreamStatusMessage(statusCode int, body []byte) string {
+ base := aiStatusMessage(statusCode)
+ detail := aiUpstreamErrorDetail(body)
+ if detail == "" {
+ return base
+ }
+ return base + ":" + detail
+}
+
+func aiUpstreamErrorDetail(body []byte) string {
+ text := strings.TrimSpace(string(body))
+ if text == "" {
+ return ""
+ }
+ var payload struct {
+ Msg string `json:"msg"`
+ Message string `json:"message"`
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(body, &payload); err == nil {
+ if payload.Error.Message != "" {
+ if payload.Error.Code != "" {
+ return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message)
+ }
+ return safeUpstreamText(payload.Error.Message)
+ }
+ if payload.Msg != "" {
+ return safeUpstreamText(payload.Msg)
+ }
+ if payload.Message != "" {
+ return safeUpstreamText(payload.Message)
+ }
+ }
+ return safeUpstreamText(text)
+}
+
+func safeUpstreamText(text string) string {
+ text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
+ runes := []rune(text)
+ if len(runes) > 300 {
+ return string(runes[:300]) + "..."
+ }
+ return text
+}
+
type aiError struct {
message string
}
diff --git a/handler/ai_test.go b/handler/ai_test.go
new file mode 100644
index 0000000..2c4d8d4
--- /dev/null
+++ b/handler/ai_test.go
@@ -0,0 +1,20 @@
+package handler
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAIUpstreamErrorDetail(t *testing.T) {
+ got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InvalidParameter","message":"reference video fps is invalid"}}`))
+ if got != "InvalidParameter reference video fps is invalid" {
+ t.Fatalf("detail = %q", got)
+ }
+}
+
+func TestSafeUpstreamTextTruncates(t *testing.T) {
+ got := safeUpstreamText(strings.Repeat("错", 320))
+ if len([]rune(got)) != 303 {
+ t.Fatalf("truncated rune length = %d", len([]rune(got)))
+ }
+}
diff --git a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
index 8966229..f910ed7 100644
--- a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
+++ b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
@@ -9,6 +9,7 @@ import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
+import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
@@ -303,7 +304,7 @@ function ImageSortCard({

-
{imageIndex + 1}
+
{seedanceReferenceLabel("image", imageIndex)}
onMove(input, offset)} />
@@ -328,7 +329,7 @@ function VideoSortCard({
- {videoIndex + 1}
+ {seedanceReferenceLabel("video", videoIndex)}
onMove(input, offset)} />
@@ -358,7 +359,7 @@ function AudioSortCard({
} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
- {audioIndex + 1}
+ {seedanceReferenceLabel("audio", audioIndex)}
} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
diff --git a/web/src/app/(user)/canvas/components/canvas-node-generation.ts b/web/src/app/(user)/canvas/components/canvas-node-generation.ts
index 3a3f1a4..abde93a 100644
--- a/web/src/app/(user)/canvas/components/canvas-node-generation.ts
+++ b/web/src/app/(user)/canvas/components/canvas-node-generation.ts
@@ -102,6 +102,10 @@ function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content,
storageKey: node.metadata.storageKey,
+ bytes: node.metadata.bytes,
+ width: node.metadata.naturalWidth,
+ height: node.metadata.naturalHeight,
+ durationMs: node.metadata.durationMs,
};
}
diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx
index 7a6b009..2cbd3c7 100644
--- a/web/src/app/(user)/video/page.tsx
+++ b/web/src/app/(user)/video/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
+import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
import localforage from "localforage";
@@ -13,7 +13,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, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
+import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
@@ -126,7 +126,7 @@ export default function VideoPage() {
const nextVideoReferences = await Promise.all(
videoFiles.map(async (file) => {
const video = await uploadMediaFile(file, "video-reference");
- return { id: nanoid(), name: file.name, type: video.mimeType, url: video.url, storageKey: video.storageKey, durationMs: video.durationMs };
+ return { id: nanoid(), name: file.name, type: video.mimeType, url: video.url, storageKey: video.storageKey, bytes: video.bytes, width: video.width, height: video.height, durationMs: video.durationMs };
}),
);
const nextAudioReferences = filterAudioReferencesByDuration(
@@ -209,6 +209,11 @@ export default function VideoPage() {
openConfigDialog(true);
return null;
}
+ const videoReferenceError = seedanceVideoReferenceError(videoReferences);
+ if (videoReferenceError) {
+ message.error(`${videoReferenceError}。${seedanceVideoReferenceHint}`);
+ return null;
+ }
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences], audioReferences: [...audioReferences] };
};
@@ -240,7 +245,7 @@ export default function VideoPage() {
const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
} else if (payload.kind === "video") {
- setVideoReferences((value) => [...value, { id: nanoid(), name: payload.title, type: "video/mp4", url: payload.url, storageKey: payload.storageKey }].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
+ setVideoReferences((value) => [...value, { id: nanoid(), name: payload.title, type: "video/mp4", url: payload.url, storageKey: payload.storageKey, width: payload.width, height: payload.height }].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
}
setAssetPickerOpen(false);
};
@@ -343,9 +348,11 @@ export default function VideoPage() {
- {references.map((item) => (
+ {references.map((item, index) => (

+
{seedanceReferenceLabel("image", index)}
+
setReferences((value) => moveListItem(value, index, offset))} />
@@ -363,9 +370,11 @@ export default function VideoPage() {
- {videoReferences.map((item) => (
+ {videoReferences.map((item, index) => (
+ {seedanceReferenceLabel("video", index)}
+ setVideoReferences((value) => moveListItem(value, index, offset))} />
@@ -383,13 +392,15 @@ export default function VideoPage() {
- {audioReferences.map((item) => (
+ {audioReferences.map((item, index) => (
+ {seedanceReferenceLabel("audio", index)}
{item.name}
+
setAudioReferences((value) => moveListItem(value, index, offset))} />
@@ -698,6 +709,24 @@ function filterAudioReferencesByDuration(existing: ReferenceAudio[], next: Refer
return accepted;
}
+function moveListItem(items: T[], index: number, offset: number) {
+ const targetIndex = index + offset;
+ if (targetIndex < 0 || targetIndex >= items.length) return items;
+ const next = [...items];
+ [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
+ return next;
+}
+
+function ReferenceOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
+ if (total <= 1) return null;
+ return (
+
+ } disabled={index <= 0} onClick={() => onMove(-1)} />
+ } disabled={index >= total - 1} onClick={() => onMove(1)} />
+
+ );
+}
+
function normalizeLogConfig(log: Partial): GenerationLogConfig {
return {
model: log.config?.model || log.model || "",
diff --git a/web/src/lib/seedance-video.ts b/web/src/lib/seedance-video.ts
index 35c00b9..4194396 100644
--- a/web/src/lib/seedance-video.ts
+++ b/web/src/lib/seedance-video.ts
@@ -1,4 +1,6 @@
import type { AiConfig } from "@/stores/use-config-store";
+import type { ReferenceImage } from "@/types/image";
+import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
export const SEEDANCE_REFERENCE_LIMITS = {
images: 9,
@@ -123,3 +125,44 @@ export function boolConfig(value: string | undefined, fallback: boolean) {
if (value === "false") return false;
return fallback;
}
+
+export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) {
+ if (kind === "image") return `图片${index + 1}`;
+ if (kind === "video") return `视频${index + 1}`;
+ return `音频${index + 1}`;
+}
+
+export function buildSeedancePromptText(prompt: string, images: ReferenceImage[], videos: ReferenceVideo[], audios: ReferenceAudio[]) {
+ const labels = [
+ ...images.map((_, index) => seedanceReferenceLabel("image", index)),
+ ...videos.map((_, index) => seedanceReferenceLabel("video", index)),
+ ...audios.map((_, index) => seedanceReferenceLabel("audio", index)),
+ ];
+ const text = prompt.trim();
+ if (!labels.length) return text;
+ return `参考素材编号:${labels.join("、")}。请按这些编号理解提示词中的图片、视频和音频引用。\n\n${text}`;
+}
+
+export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
+ let totalDurationMs = 0;
+ 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 (video.durationMs) {
+ if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
+ totalDurationMs += video.durationMs;
+ }
+ if (video.width && video.height) {
+ if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return `${label} 宽高需要在 300-6000px 之间`;
+ 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 (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒";
+ return "";
+}
+
+export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts
index 37522f8..ef02431 100644
--- a/web/src/services/api/video.ts
+++ b/web/src/services/api/video.ts
@@ -3,7 +3,7 @@ import axios from "axios";
import { dataUrlToFile } from "@/lib/image-utils";
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
-import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
+import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { ReferenceImage } from "@/types/image";
@@ -130,6 +130,8 @@ async function requestSeedanceGeneration(config: AiConfig, model: string, prompt
}
function assertSeedanceVideoReferences(videoReferences: ReferenceVideo[]) {
+ const error = seedanceVideoReferenceError(videoReferences);
+ if (error) throw new Error(error);
let total = 0;
for (const video of videoReferences) {
if (!video.durationMs) continue;
@@ -156,7 +158,7 @@ function seedanceApiUrl(config: AiConfig, taskId?: string) {
async function buildSeedanceContent(config: AiConfig, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) {
const content: Array> = [];
- const text = prompt.trim();
+ const text = buildSeedancePromptText(prompt, references, videoReferences, audioReferences);
if (text) content.push({ type: "text", text });
for (const image of references.slice(0, SEEDANCE_REFERENCE_LIMITS.images)) {
content.push({ type: "image_url", image_url: { url: await resolveSeedanceImageUrl(config, image) }, role: "reference_image" });
diff --git a/web/src/types/media.ts b/web/src/types/media.ts
index 84d4e2d..dd1454c 100644
--- a/web/src/types/media.ts
+++ b/web/src/types/media.ts
@@ -4,6 +4,9 @@ export type ReferenceVideo = {
type: string;
url: string;
storageKey?: string;
+ bytes?: number;
+ width?: number;
+ height?: number;
durationMs?: number;
};