diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py
index 36ec146..ed9db0d 100644
--- a/backend/apps/generation/views.py
+++ b/backend/apps/generation/views.py
@@ -1,4 +1,5 @@
import logging
+import re
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, parser_classes
@@ -90,6 +91,17 @@ def _format_prompt_for_ark(prompt, label_placeholders):
return result
+_ORPHAN_MATERIAL_MENTION_RE = re.compile(r'@(?:图片|视频|音频|素材)[^\s,。!?、;:,.!?;:))]*')
+
+
+def _find_orphan_material_mention(prompt, references):
+ """Return a material @mention in prompt when no reference payload arrived."""
+ if not prompt or references:
+ return None
+ match = _ORPHAN_MATERIAL_MENTION_RE.search(prompt)
+ return match.group(0) if match else None
+
+
def _get_token_price(config, model, has_video_ref, resolution):
"""根据模型、是否含视频、分辨率选择单价。
@@ -226,9 +238,22 @@ def video_generate_view(request):
aspect_ratio = serializer.validated_data['aspect_ratio']
# serializer 已设 default='720p' + choices 约束,validated_data 必有合法值
resolution = serializer.validated_data['resolution']
+ references = serializer.validated_data.get('references', [])
search_mode = request.data.get('search_mode', 'off')
seed = _safe_int(request.data.get('seed', -1), -1)
+ orphan_mention = _find_orphan_material_mention(prompt, references)
+ if orphan_mention:
+ logger.warning(
+ 'Blocked generate with material @mention but empty references (user=%s prompt=%s)',
+ user.id,
+ prompt[:120],
+ )
+ return Response({
+ 'error': 'missing_references',
+ 'message': f'{orphan_mention} 对应的内容为空',
+ }, status=status.HTTP_400_BAD_REQUEST)
+
# 1080P 仅 Seedance 2.0 支持,Fast 不支持
if resolution == '1080p' and model == 'seedance_2.0_fast':
return Response({
@@ -238,7 +263,6 @@ def video_generate_view(request):
# ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0]
- references = request.data.get('references', [])
w, h = get_resolution(aspect_ratio, resolution)
has_video_ref = _has_video_reference(references)
input_video_dur = _sum_video_duration(references) if has_video_ref else 0
@@ -335,9 +359,12 @@ def video_generate_view(request):
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# 构建参考素材
- references = request.data.get('references', [])
- # 火山限制最多 9 张参考图片
- image_count = sum(1 for r in references if r.get('type', 'image') == 'image')
+ # 直接上传的参考图片最多 9 张;素材库 asset:// 引用不计入该上传槽位限制。
+ image_count = sum(
+ 1 for r in references
+ if r.get('type', 'image') == 'image'
+ and not str(r.get('url', '')).startswith('asset://')
+ )
if image_count > 9:
return Response({
'error': 'too_many_references',
diff --git a/backend/tests/test_ark_prompt_format.py b/backend/tests/test_ark_prompt_format.py
index 6922cfe..6e5b536 100644
--- a/backend/tests/test_ark_prompt_format.py
+++ b/backend/tests/test_ark_prompt_format.py
@@ -146,6 +146,16 @@ class TestVideoGenerateArkPrompt(TestCase):
'references': references,
}, format='json')
+ @mock.patch('apps.generation.views.create_task')
+ def test_reject_material_mention_without_references(self, mock_create_task):
+ """事故回归:prompt 含 @图片 但 references 为空时,不能创建 refs=0 脏任务。"""
+ resp = self._post_generate('@图片1 走过来', [])
+ self.assertEqual(resp.status_code, 400, resp.content)
+ self.assertEqual(resp.json().get('error'), 'missing_references')
+ self.assertEqual(resp.json().get('message'), '@图片1 对应的内容为空')
+ self.assertFalse(mock_create_task.called)
+ self.assertFalse(GenerationRecord.objects.filter(user=self.user).exists())
+
@mock.patch('apps.generation.tasks.poll_video_task')
@mock.patch('apps.generation.views.create_task')
def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):
diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx
index 1fa225a..7666071 100644
--- a/web/src/components/PromptInput.tsx
+++ b/web/src/components/PromptInput.tsx
@@ -416,14 +416,15 @@ export function PromptInput() {
}, [extractText]);
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
- // Instant check: count limit
+ // Instant check: count limit. Image assets from the library do not consume
+ // the 9 direct-upload image slots.
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
const refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++);
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
- const maxMap = { image: 9, video: 3, audio: 3 };
- if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
+ const maxMap = { video: 3, audio: 3 };
+ if (typeKey !== 'image' && refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
showToast(`${typeLabel}已达上限`);
return;
@@ -715,12 +716,16 @@ export function PromptInput() {
}}
>
- {ref.type === 'video' ? (
+ {ref.uploading ? (
+
...
+ ) : ref.type === 'video' && ref.previewUrl ? (
) : ref.type === 'audio' ? (
{'\u266B'}
- ) : (
+ ) : ref.previewUrl ? (

+ ) : (
+
?
)}
{ref.label}
diff --git a/web/src/components/UniversalUpload.module.css b/web/src/components/UniversalUpload.module.css
index 09a1ba0..c3d69cc 100644
--- a/web/src/components/UniversalUpload.module.css
+++ b/web/src/components/UniversalUpload.module.css
@@ -282,6 +282,16 @@
color: var(--color-text-secondary);
}
+.uploadPlaceholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-overlay-soft);
+ color: var(--color-text-secondary);
+}
+
/* Upload status overlay */
.uploadOverlay {
position: absolute;
diff --git a/web/src/components/UniversalUpload.tsx b/web/src/components/UniversalUpload.tsx
index 5e23475..6939657 100644
--- a/web/src/components/UniversalUpload.tsx
+++ b/web/src/components/UniversalUpload.tsx
@@ -142,28 +142,29 @@ export function UniversalUpload() {
}}
>
- {ref.type === 'video' ? (
+ {ref.uploading ? (
+
+
+
+ ) : ref.uploadError ? (
+
{ e.stopPropagation(); retryUpload(ref.id); }}
+ title="点击重试"
+ >
+
+
+ ) : ref.type === 'video' && ref.previewUrl ? (
) : ref.type === 'audio' ? (
- ) : (
+ ) : ref.previewUrl ? (

{ e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
- )}
- {/* Upload status overlay */}
- {ref.uploading && (
-
-
-
- )}
- {ref.uploadError && (
-
{ e.stopPropagation(); retryUpload(ref.id); }}
- title="点击重试"
- >
-
+ ) : (
+
)}
): string | null {
+ if (input.mode !== 'universal') return null;
+ const hasDirectRefs = input.references.length > 0;
+ const hasAssetMentions = (input.assetMentions || []).length > 0;
+ if (hasDirectRefs || hasAssetMentions) return null;
+ return input.prompt.match(ORPHAN_MATERIAL_MENTION_RE)?.[0] || null;
+}
+
+function isBlobUrl(url?: string): boolean {
+ return !!url && url.startsWith('blob:');
+}
+
+function isOnlineUrl(url?: string): boolean {
+ return !!url && /^https?:\/\//i.test(url);
+}
+
+function isSubmittedMaterialUrl(url?: string): boolean {
+ return isOnlineUrl(url) || !!url?.startsWith('asset://');
+}
+
+function hasBlobResourceInEditorHtml(editorHtml?: string): boolean {
+ return !!editorHtml && editorHtml.includes('blob:');
+}
+
+function hasBlobResourceInAssetMentions(assetMentions?: Record
[]): boolean {
+ return !!assetMentions?.some((mention) =>
+ isBlobUrl(typeof mention.thumbUrl === 'string' ? mention.thumbUrl : undefined)
+ );
+}
+
+function hasBlobResourceBeforeSubmit(input: ReturnType): boolean {
+ if (input.mode !== 'universal') return false;
+ if (hasBlobResourceInEditorHtml(input.editorHtml)) return true;
+ if (hasBlobResourceInAssetMentions(input.assetMentions)) return true;
+ return input.references.some((ref) =>
+ isBlobUrl(ref.previewUrl) || isBlobUrl(ref.tosUrl)
+ );
+}
+
+function hasUnuploadedDirectReference(input: ReturnType): boolean {
+ if (input.mode !== 'universal') return false;
+ return input.references.some((ref) => !isOnlineUrl(ref.tosUrl));
+}
+
+function hasBlobResourceInUploadedRefs(
+ refs: { url: string; thumb_url?: string }[],
+): boolean {
+ return refs.some((ref) => isBlobUrl(ref.url) || isBlobUrl(ref.thumb_url));
+}
+
+function hasInvalidSubmittedMaterialUrl(
+ refs: { url: string; thumb_url?: string }[],
+): boolean {
+ return refs.some((ref) =>
+ !isSubmittedMaterialUrl(ref.url) ||
+ (!!ref.thumb_url && !isOnlineUrl(ref.thumb_url))
+ );
+}
+
/** Check if a URL is an asset library reference (case-insensitive protocol). */
function isAssetUrl(url: string): boolean {
return url.startsWith('asset://') || url.startsWith('Asset://');
@@ -314,7 +376,16 @@ export const useGenerationStore = create((set, get) => ({
addTask: async () => {
const input = useInputBarStore.getState();
+ const orphanMention = findOrphanMaterialMention(input);
+ if (orphanMention) {
+ showToast(`${orphanMention} 对应的内容为空`);
+ return null;
+ }
if (!input.canSubmit()) return null;
+ if (hasBlobResourceBeforeSubmit(input) || hasUnuploadedDirectReference(input)) {
+ showToast(RESOURCE_UPLOAD_FAILED_MESSAGE);
+ return null;
+ }
// Collect files to upload (or existing TOS URLs for regeneration)
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
@@ -516,6 +587,16 @@ export const useGenerationStore = create((set, get) => ({
}
}
+ if (hasBlobResourceInUploadedRefs(uploadedRefs) || hasInvalidSubmittedMaterialUrl(uploadedRefs)) {
+ showToast(RESOURCE_UPLOAD_FAILED_MESSAGE);
+ set((s) => ({
+ tasks: s.tasks.map((t) =>
+ t.id === tempId ? { ...t, status: 'failed' as const, progress: 0, errorMessage: RESOURCE_UPLOAD_FAILED_MESSAGE } : t
+ ),
+ }));
+ return null;
+ }
+
// Call generate API
const { data: genResult } = await videoApi.generate({
prompt: input.prompt,
diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts
index 9dcfd23..796012c 100644
--- a/web/src/store/inputBar.ts
+++ b/web/src/store/inputBar.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
-import { mediaApi } from '../lib/api';
+import { mediaApi, tosThumb } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions';
let fileCounter = 0;
@@ -65,6 +65,70 @@ const MAX_IMAGES = 9;
const MAX_VIDEOS = 3;
const MAX_AUDIO = 3;
+function isBlobUrl(url?: string): boolean {
+ return !!url && url.startsWith('blob:');
+}
+
+function revokePreviewUrl(url?: string) {
+ if (url && isBlobUrl(url)) URL.revokeObjectURL(url);
+}
+
+function syncEditorReferencePreview(editorHtml: string, refId: string, previewUrl: string): string {
+ if (!editorHtml) return editorHtml;
+ const doc = new DOMParser().parseFromString(`${editorHtml}
`, 'text/html');
+ const container = doc.body.firstElementChild as HTMLElement | null;
+ if (!container) return editorHtml;
+
+ let changed = false;
+ container.querySelectorAll('[data-ref-id]').forEach((span) => {
+ const el = span as HTMLElement;
+ if (el.dataset.refId !== refId || el.dataset.refType === 'asset') return;
+ el.dataset.thumbUrl = previewUrl;
+ if (el.dataset.refType !== 'audio') {
+ let img = el.querySelector('img') as HTMLImageElement | null;
+ if (!img) {
+ const newImg = document.createElement('img');
+ newImg.className = 'mentionImg';
+ newImg.setAttribute('width', '16');
+ newImg.setAttribute('height', '16');
+ newImg.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
+ newImg.onerror = () => { newImg.style.display = 'none'; };
+ el.insertBefore(newImg, el.firstChild);
+ img = newImg;
+ }
+ img.src = tosThumb(previewUrl, 32);
+ }
+ changed = true;
+ });
+
+ return changed ? container.innerHTML : editorHtml;
+}
+
+function markReferenceUploadSuccess(
+ refs: UploadedFile[],
+ refId: string,
+ uploadedUrl: string,
+ blobUrlsToRevoke: Set,
+): UploadedFile[] {
+ return refs.map((ref) => {
+ if (ref.id !== refId) return ref;
+ if (isBlobUrl(ref.previewUrl)) blobUrlsToRevoke.add(ref.previewUrl);
+ return {
+ ...ref,
+ tosUrl: uploadedUrl,
+ previewUrl: uploadedUrl,
+ uploading: false,
+ uploadError: false,
+ };
+ });
+}
+
+function markReferenceUploadError(refs: UploadedFile[], refId: string): UploadedFile[] {
+ return refs.map((ref) =>
+ ref.id === refId ? { ...ref, uploading: false, uploadError: true } : ref
+ );
+}
+
interface InputBarState {
// Generation type
generationType: GenerationType;
@@ -197,13 +261,10 @@ export const useInputBarStore = create((set, get) => ({
prevReferences: [],
addReferences: (files) => {
const state = get();
- // Count existing references by type + merge @ asset mentions
+ // Count direct uploaded references by type. Asset library mentions do not
+ // consume direct upload slots.
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
- const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
- counts.image += assetCounts.image;
- counts.video += assetCounts.video;
- counts.audio += assetCounts.audio;
// Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = [];
@@ -245,7 +306,7 @@ export const useInputBarStore = create((set, get) => ({
const state = get();
const removedRef = state.references.find((r) => r.id === id);
if (!removedRef) return;
- if (removedRef.previewUrl) URL.revokeObjectURL(removedRef.previewUrl);
+ revokePreviewUrl(removedRef.previewUrl);
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
const remaining = state.references.filter((r) => r.id !== id);
@@ -283,7 +344,7 @@ export const useInputBarStore = create((set, get) => ({
},
clearReferences: () => {
const state = get();
- state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
+ state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
set({ references: [] });
},
retryUpload: (refId) => {
@@ -301,7 +362,7 @@ export const useInputBarStore = create((set, get) => ({
lastFrame: null,
setFirstFrame: (file) => {
const state = get();
- if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
+ if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
if (file) {
fileCounter++;
set({
@@ -319,7 +380,7 @@ export const useInputBarStore = create((set, get) => ({
},
setLastFrame: (file) => {
const state = get();
- if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
+ if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
if (file) {
fileCounter++;
set({
@@ -391,8 +452,8 @@ export const useInputBarStore = create((set, get) => ({
});
} else {
// Clear keyframe
- if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
- if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
+ if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
+ if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
set({
mode,
assetMentions: [],
@@ -412,10 +473,10 @@ export const useInputBarStore = create((set, get) => ({
reset: () => {
const state = get();
- state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
- state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl));
- if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
- if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
+ state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
+ state.prevReferences.forEach((r) => revokePreviewUrl(r.previewUrl));
+ if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
+ if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
set({
mode: 'universal',
model: 'seedance_2.0',
@@ -441,16 +502,17 @@ export const useInputBarStore = create((set, get) => ({
/** Upload a single reference file to TOS and update the store. */
function _uploadRef(refId: string, file: File) {
mediaApi.upload(file).then(({ data }) => {
+ const blobUrlsToRevoke = new Set();
useInputBarStore.setState((s) => ({
- references: s.references.map((r) =>
- r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r
- ),
+ references: markReferenceUploadSuccess(s.references, refId, data.url, blobUrlsToRevoke),
+ prevReferences: markReferenceUploadSuccess(s.prevReferences, refId, data.url, blobUrlsToRevoke),
+ editorHtml: syncEditorReferencePreview(s.editorHtml, refId, data.url),
}));
+ blobUrlsToRevoke.forEach((url) => URL.revokeObjectURL(url));
}).catch(() => {
useInputBarStore.setState((s) => ({
- references: s.references.map((r) =>
- r.id === refId ? { ...r, uploading: false, uploadError: true } : r
- ),
+ references: markReferenceUploadError(s.references, refId),
+ prevReferences: markReferenceUploadError(s.prevReferences, refId),
}));
});
}
@@ -494,14 +556,13 @@ async function _validateAndAddImages(files: File[]) {
// Passed — add to store + upload
fileCounter++;
- const existingSameType = state.references.filter(r => r.type === 'image').length
- + (state.assetMentions || []).filter((m: Record) => m.type === 'image').length;
+ const existingSameType = state.references.filter(r => r.type === 'image').length;
const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = {
id: refId,
file,
type: 'image',
- previewUrl: URL.createObjectURL(file),
+ previewUrl: '',
label: `图片${existingSameType + 1}`,
uploading: true,
};
@@ -594,14 +655,13 @@ async function _validateAndAddMedia(files: File[]) {
// Passed all checks — add to store
fileCounter++;
const labelPrefix = type === 'video' ? '视频' : '音频';
- const existingSameType = state.references.filter(r => r.type === type).length
- + (state.assetMentions || []).filter((m: Record) => m.type === type).length;
+ const existingSameType = state.references.filter(r => r.type === type).length;
const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = {
id: refId,
file,
type,
- previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
+ previewUrl: '',
label: `${labelPrefix}${existingSameType + 1}`,
uploading: true,
duration: Math.round(dur * 10) / 10,