Merge dev into master
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m59s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m59s
This commit is contained in:
commit
f7e2fb8ae2
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes, parser_classes
|
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
|
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):
|
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']
|
aspect_ratio = serializer.validated_data['aspect_ratio']
|
||||||
# serializer 已设 default='720p' + choices 约束,validated_data 必有合法值
|
# serializer 已设 default='720p' + choices 约束,validated_data 必有合法值
|
||||||
resolution = serializer.validated_data['resolution']
|
resolution = serializer.validated_data['resolution']
|
||||||
|
references = serializer.validated_data.get('references', [])
|
||||||
search_mode = request.data.get('search_mode', 'off')
|
search_mode = request.data.get('search_mode', 'off')
|
||||||
seed = _safe_int(request.data.get('seed', -1), -1)
|
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 不支持
|
# 1080P 仅 Seedance 2.0 支持,Fast 不支持
|
||||||
if resolution == '1080p' and model == 'seedance_2.0_fast':
|
if resolution == '1080p' and model == 'seedance_2.0_fast':
|
||||||
return Response({
|
return Response({
|
||||||
@ -238,7 +263,6 @@ def video_generate_view(request):
|
|||||||
|
|
||||||
# ── 预估 token 和费用 ──
|
# ── 预估 token 和费用 ──
|
||||||
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||||||
references = request.data.get('references', [])
|
|
||||||
w, h = get_resolution(aspect_ratio, resolution)
|
w, h = get_resolution(aspect_ratio, resolution)
|
||||||
has_video_ref = _has_video_reference(references)
|
has_video_ref = _has_video_reference(references)
|
||||||
input_video_dur = _sum_video_duration(references) if has_video_ref else 0
|
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)
|
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||||
|
|
||||||
# 构建参考素材
|
# 构建参考素材
|
||||||
references = request.data.get('references', [])
|
# 直接上传的参考图片最多 9 张;素材库 asset:// 引用不计入该上传槽位限制。
|
||||||
# 火山限制最多 9 张参考图片
|
image_count = sum(
|
||||||
image_count = sum(1 for r in references if r.get('type', 'image') == 'image')
|
1 for r in references
|
||||||
|
if r.get('type', 'image') == 'image'
|
||||||
|
and not str(r.get('url', '')).startswith('asset://')
|
||||||
|
)
|
||||||
if image_count > 9:
|
if image_count > 9:
|
||||||
return Response({
|
return Response({
|
||||||
'error': 'too_many_references',
|
'error': 'too_many_references',
|
||||||
|
|||||||
@ -146,6 +146,16 @@ class TestVideoGenerateArkPrompt(TestCase):
|
|||||||
'references': references,
|
'references': references,
|
||||||
}, format='json')
|
}, 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.tasks.poll_video_task')
|
||||||
@mock.patch('apps.generation.views.create_task')
|
@mock.patch('apps.generation.views.create_task')
|
||||||
def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):
|
def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):
|
||||||
|
|||||||
@ -416,14 +416,15 @@ export function PromptInput() {
|
|||||||
}, [extractText]);
|
}, [extractText]);
|
||||||
|
|
||||||
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
|
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 stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
|
||||||
const refs = useInputBarStore.getState().references;
|
const refs = useInputBarStore.getState().references;
|
||||||
const refCounts = { image: 0, video: 0, audio: 0 };
|
const refCounts = { image: 0, video: 0, audio: 0 };
|
||||||
refs.forEach((r) => refCounts[r.type]++);
|
refs.forEach((r) => refCounts[r.type]++);
|
||||||
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
|
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
|
||||||
const maxMap = { image: 9, video: 3, audio: 3 };
|
const maxMap = { video: 3, audio: 3 };
|
||||||
if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
|
if (typeKey !== 'image' && refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
|
||||||
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
|
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
|
||||||
showToast(`${typeLabel}已达上限`);
|
showToast(`${typeLabel}已达上限`);
|
||||||
return;
|
return;
|
||||||
@ -715,12 +716,16 @@ export function PromptInput() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.mentionThumb}>
|
<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} />
|
<video src={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
<span style={{ fontSize: 16 }}>{'\u266B'}</span>
|
<span style={{ fontSize: 16 }}>{'\u266B'}</span>
|
||||||
) : (
|
) : ref.previewUrl ? (
|
||||||
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
|
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12 }}>?</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.mentionLabel}>{ref.label}</span>
|
<span className={styles.mentionLabel}>{ref.label}</span>
|
||||||
|
|||||||
@ -282,6 +282,16 @@
|
|||||||
color: var(--color-text-secondary);
|
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 */
|
/* Upload status overlay */
|
||||||
.uploadOverlay {
|
.uploadOverlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -142,28 +142,29 @@ export function UniversalUpload() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.thumbInner}>
|
<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 />
|
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.thumbMedia} muted />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
<div className={styles.audioPlaceholder}>
|
<div className={styles.audioPlaceholder}>
|
||||||
<AudioIcon />
|
<AudioIcon />
|
||||||
</div>
|
</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); }} />
|
<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 */}
|
<div className={styles.audioPlaceholder}>
|
||||||
{ref.uploading && (
|
<AudioIcon />
|
||||||
<div className={styles.uploadOverlay}>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ref.uploadError && (
|
|
||||||
<div
|
|
||||||
className={`${styles.uploadOverlay} ${styles.uploadError}`}
|
|
||||||
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
|
|
||||||
title="点击重试"
|
|
||||||
>
|
|
||||||
<ErrorIcon />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -54,6 +54,68 @@ function mapProgress(backendStatus: string): number {
|
|||||||
return 50;
|
return 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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<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). */
|
/** Check if a URL is an asset library reference (case-insensitive protocol). */
|
||||||
function isAssetUrl(url: string): boolean {
|
function isAssetUrl(url: string): boolean {
|
||||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||||
@ -314,7 +376,16 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
|
|
||||||
addTask: async () => {
|
addTask: async () => {
|
||||||
const input = useInputBarStore.getState();
|
const input = useInputBarStore.getState();
|
||||||
|
const orphanMention = findOrphanMaterialMention(input);
|
||||||
|
if (orphanMention) {
|
||||||
|
showToast(`${orphanMention} 对应的内容为空`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!input.canSubmit()) 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)
|
// Collect files to upload (or existing TOS URLs for regeneration)
|
||||||
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
|
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
|
||||||
@ -516,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
|
// Call generate API
|
||||||
const { data: genResult } = await videoApi.generate({
|
const { data: genResult } = await videoApi.generate({
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
|
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
import { mediaApi } from '../lib/api';
|
import { mediaApi, tosThumb } from '../lib/api';
|
||||||
import { parseAssetMentions } from '../lib/assetMentions';
|
import { parseAssetMentions } from '../lib/assetMentions';
|
||||||
|
|
||||||
let fileCounter = 0;
|
let fileCounter = 0;
|
||||||
@ -65,6 +65,70 @@ const MAX_IMAGES = 9;
|
|||||||
const MAX_VIDEOS = 3;
|
const MAX_VIDEOS = 3;
|
||||||
const MAX_AUDIO = 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 {
|
interface InputBarState {
|
||||||
// Generation type
|
// Generation type
|
||||||
generationType: GenerationType;
|
generationType: GenerationType;
|
||||||
@ -197,13 +261,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
prevReferences: [],
|
prevReferences: [],
|
||||||
addReferences: (files) => {
|
addReferences: (files) => {
|
||||||
const state = get();
|
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 };
|
const counts = { image: 0, video: 0, audio: 0 };
|
||||||
for (const ref of state.references) counts[ref.type]++;
|
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)
|
// Separate images (sync) from audio/video (need async duration check)
|
||||||
const imageFiles: File[] = [];
|
const imageFiles: File[] = [];
|
||||||
@ -245,7 +306,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
const state = get();
|
const state = get();
|
||||||
const removedRef = state.references.find((r) => r.id === id);
|
const removedRef = state.references.find((r) => r.id === id);
|
||||||
if (!removedRef) return;
|
if (!removedRef) return;
|
||||||
if (removedRef.previewUrl) URL.revokeObjectURL(removedRef.previewUrl);
|
revokePreviewUrl(removedRef.previewUrl);
|
||||||
|
|
||||||
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
|
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
|
||||||
const remaining = state.references.filter((r) => r.id !== id);
|
const remaining = state.references.filter((r) => r.id !== id);
|
||||||
@ -283,7 +344,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
clearReferences: () => {
|
clearReferences: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
|
state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||||||
set({ references: [] });
|
set({ references: [] });
|
||||||
},
|
},
|
||||||
retryUpload: (refId) => {
|
retryUpload: (refId) => {
|
||||||
@ -301,7 +362,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
lastFrame: null,
|
lastFrame: null,
|
||||||
setFirstFrame: (file) => {
|
setFirstFrame: (file) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
|
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||||||
if (file) {
|
if (file) {
|
||||||
fileCounter++;
|
fileCounter++;
|
||||||
set({
|
set({
|
||||||
@ -319,7 +380,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
setLastFrame: (file) => {
|
setLastFrame: (file) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
|
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||||||
if (file) {
|
if (file) {
|
||||||
fileCounter++;
|
fileCounter++;
|
||||||
set({
|
set({
|
||||||
@ -391,8 +452,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Clear keyframe
|
// Clear keyframe
|
||||||
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
|
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||||||
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
|
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||||||
set({
|
set({
|
||||||
mode,
|
mode,
|
||||||
assetMentions: [],
|
assetMentions: [],
|
||||||
@ -412,10 +473,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
|
state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||||||
state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl));
|
state.prevReferences.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||||||
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
|
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||||||
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
|
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||||||
set({
|
set({
|
||||||
mode: 'universal',
|
mode: 'universal',
|
||||||
model: 'seedance_2.0',
|
model: 'seedance_2.0',
|
||||||
@ -441,16 +502,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
/** Upload a single reference file to TOS and update the store. */
|
/** Upload a single reference file to TOS and update the store. */
|
||||||
function _uploadRef(refId: string, file: File) {
|
function _uploadRef(refId: string, file: File) {
|
||||||
mediaApi.upload(file).then(({ data }) => {
|
mediaApi.upload(file).then(({ data }) => {
|
||||||
|
const blobUrlsToRevoke = new Set<string>();
|
||||||
useInputBarStore.setState((s) => ({
|
useInputBarStore.setState((s) => ({
|
||||||
references: s.references.map((r) =>
|
references: markReferenceUploadSuccess(s.references, refId, data.url, blobUrlsToRevoke),
|
||||||
r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r
|
prevReferences: markReferenceUploadSuccess(s.prevReferences, refId, data.url, blobUrlsToRevoke),
|
||||||
),
|
editorHtml: syncEditorReferencePreview(s.editorHtml, refId, data.url),
|
||||||
}));
|
}));
|
||||||
|
blobUrlsToRevoke.forEach((url) => URL.revokeObjectURL(url));
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
useInputBarStore.setState((s) => ({
|
useInputBarStore.setState((s) => ({
|
||||||
references: s.references.map((r) =>
|
references: markReferenceUploadError(s.references, refId),
|
||||||
r.id === refId ? { ...r, uploading: false, uploadError: true } : r
|
prevReferences: markReferenceUploadError(s.prevReferences, refId),
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -494,14 +556,13 @@ async function _validateAndAddImages(files: File[]) {
|
|||||||
|
|
||||||
// Passed — add to store + upload
|
// Passed — add to store + upload
|
||||||
fileCounter++;
|
fileCounter++;
|
||||||
const existingSameType = state.references.filter(r => r.type === 'image').length
|
const existingSameType = state.references.filter(r => r.type === 'image').length;
|
||||||
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === 'image').length;
|
|
||||||
const refId = `ref_${fileCounter}`;
|
const refId = `ref_${fileCounter}`;
|
||||||
const newRef: UploadedFile = {
|
const newRef: UploadedFile = {
|
||||||
id: refId,
|
id: refId,
|
||||||
file,
|
file,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
previewUrl: URL.createObjectURL(file),
|
previewUrl: '',
|
||||||
label: `图片${existingSameType + 1}`,
|
label: `图片${existingSameType + 1}`,
|
||||||
uploading: true,
|
uploading: true,
|
||||||
};
|
};
|
||||||
@ -594,14 +655,13 @@ async function _validateAndAddMedia(files: File[]) {
|
|||||||
// Passed all checks — add to store
|
// Passed all checks — add to store
|
||||||
fileCounter++;
|
fileCounter++;
|
||||||
const labelPrefix = type === 'video' ? '视频' : '音频';
|
const labelPrefix = type === 'video' ? '视频' : '音频';
|
||||||
const existingSameType = state.references.filter(r => r.type === type).length
|
const existingSameType = state.references.filter(r => r.type === type).length;
|
||||||
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === type).length;
|
|
||||||
const refId = `ref_${fileCounter}`;
|
const refId = `ref_${fileCounter}`;
|
||||||
const newRef: UploadedFile = {
|
const newRef: UploadedFile = {
|
||||||
id: refId,
|
id: refId,
|
||||||
file,
|
file,
|
||||||
type,
|
type,
|
||||||
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
|
previewUrl: '',
|
||||||
label: `${labelPrefix}${existingSameType + 1}`,
|
label: `${labelPrefix}${existingSameType + 1}`,
|
||||||
uploading: true,
|
uploading: true,
|
||||||
duration: Math.round(dur * 10) / 10,
|
duration: Math.round(dur * 10) / 10,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user