fix(web): require uploaded material urls before generation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9m47s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9m47s
This commit is contained in:
parent
ac12c0fbd4
commit
a08234e54b
@ -715,12 +715,16 @@ export function PromptInput() {
|
||||
}}
|
||||
>
|
||||
<div className={styles.mentionThumb}>
|
||||
{ref.type === 'video' ? (
|
||||
{ref.uploading ? (
|
||||
<span style={{ fontSize: 12 }}>...</span>
|
||||
) : ref.type === 'video' && ref.previewUrl ? (
|
||||
<video src={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} />
|
||||
) : ref.type === 'audio' ? (
|
||||
<span style={{ fontSize: 16 }}>{'\u266B'}</span>
|
||||
) : (
|
||||
) : ref.previewUrl ? (
|
||||
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
|
||||
) : (
|
||||
<span style={{ fontSize: 12 }}>?</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.mentionLabel}>{ref.label}</span>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -142,28 +142,29 @@ export function UniversalUpload() {
|
||||
}}
|
||||
>
|
||||
<div className={styles.thumbInner}>
|
||||
{ref.type === 'video' ? (
|
||||
{ref.uploading ? (
|
||||
<div className={styles.uploadPlaceholder}>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : ref.uploadError ? (
|
||||
<div
|
||||
className={`${styles.uploadPlaceholder} ${styles.uploadError}`}
|
||||
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
|
||||
title="点击重试"
|
||||
>
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
) : ref.type === 'video' && ref.previewUrl ? (
|
||||
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.thumbMedia} muted />
|
||||
) : ref.type === 'audio' ? (
|
||||
<div className={styles.audioPlaceholder}>
|
||||
<AudioIcon />
|
||||
</div>
|
||||
) : (
|
||||
) : ref.previewUrl ? (
|
||||
<img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
|
||||
)}
|
||||
{/* Upload status overlay */}
|
||||
{ref.uploading && (
|
||||
<div className={styles.uploadOverlay}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{ref.uploadError && (
|
||||
<div
|
||||
className={`${styles.uploadOverlay} ${styles.uploadError}`}
|
||||
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
|
||||
title="点击重试"
|
||||
>
|
||||
<ErrorIcon />
|
||||
) : (
|
||||
<div className={styles.audioPlaceholder}>
|
||||
<AudioIcon />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@ -55,6 +55,7 @@ function mapProgress(backendStatus: string): number {
|
||||
}
|
||||
|
||||
const ORPHAN_MATERIAL_MENTION_RE = /@(?:图片|视频|音频|素材)[^\s,。!?、;:,.!?;:))]*/;
|
||||
const RESOURCE_UPLOAD_FAILED_MESSAGE = '资源上传失败,请删除后重新上传';
|
||||
|
||||
function findOrphanMaterialMention(input: ReturnType<typeof useInputBarStore.getState>): string | null {
|
||||
if (input.mode !== 'universal') return null;
|
||||
@ -64,6 +65,57 @@ function findOrphanMaterialMention(input: ReturnType<typeof useInputBarStore.get
|
||||
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<string, unknown>[]): boolean {
|
||||
return !!assetMentions?.some((mention) =>
|
||||
isBlobUrl(typeof mention.thumbUrl === 'string' ? mention.thumbUrl : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function hasBlobResourceBeforeSubmit(input: ReturnType<typeof useInputBarStore.getState>): 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<typeof useInputBarStore.getState>): 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://');
|
||||
@ -330,6 +382,10 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
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 }[] = [];
|
||||
@ -531,6 +587,16 @@ export const useGenerationStore = create<GenerationState>((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,
|
||||
|
||||
@ -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(`<div>${editorHtml}</div>`, '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<string>,
|
||||
): 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;
|
||||
@ -245,7 +309,7 @@ export const useInputBarStore = create<InputBarState>((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 +347,7 @@ export const useInputBarStore = create<InputBarState>((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 +365,7 @@ export const useInputBarStore = create<InputBarState>((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 +383,7 @@ export const useInputBarStore = create<InputBarState>((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 +455,8 @@ export const useInputBarStore = create<InputBarState>((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 +476,10 @@ export const useInputBarStore = create<InputBarState>((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 +505,17 @@ export const useInputBarStore = create<InputBarState>((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<string>();
|
||||
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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -501,7 +566,7 @@ async function _validateAndAddImages(files: File[]) {
|
||||
id: refId,
|
||||
file,
|
||||
type: 'image',
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
previewUrl: '',
|
||||
label: `图片${existingSameType + 1}`,
|
||||
uploading: true,
|
||||
};
|
||||
@ -601,7 +666,7 @@ async function _validateAndAddMedia(files: File[]) {
|
||||
id: refId,
|
||||
file,
|
||||
type,
|
||||
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
|
||||
previewUrl: '',
|
||||
label: `${labelPrefix}${existingSameType + 1}`,
|
||||
uploading: true,
|
||||
duration: Math.round(dur * 10) / 10,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user