Merge remote-tracking branch 'origin/temptudou' into dev
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
This commit is contained in:
commit
06587edc10
@ -333,6 +333,13 @@ def video_generate_view(request):
|
|||||||
continue
|
continue
|
||||||
seen_urls.add(original_url)
|
seen_urls.add(original_url)
|
||||||
|
|
||||||
|
# 拦截 blob: URL(前端上传失败的兜底)
|
||||||
|
if url.startswith('blob:'):
|
||||||
|
return Response({
|
||||||
|
'error': 'upload_failed',
|
||||||
|
'message': f'素材「{label}」上传失败,请删除后重新添加',
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 快照存原始 URL(前端重建 reEdit 需要 asset://group-{id} 格式)
|
# 快照存原始 URL(前端重建 reEdit 需要 asset://group-{id} 格式)
|
||||||
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
|
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
|
||||||
thumb_url = ref.get('thumb_url', '')
|
thumb_url = ref.get('thumb_url', '')
|
||||||
|
|||||||
BIN
backend/db.sqlite3.bak
Normal file
BIN
backend/db.sqlite3.bak
Normal file
Binary file not shown.
@ -21,6 +21,8 @@ ERROR_MESSAGES = {
|
|||||||
'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
|
'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
|
||||||
'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
|
'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
|
||||||
'InvalidAudio': '音频格式不符合要求,请检查后重试',
|
'InvalidAudio': '音频格式不符合要求,请检查后重试',
|
||||||
|
'AudioDurationExceeded': '音频总时长超过15秒限制,请缩短音频后重试',
|
||||||
|
'AudioFormatNotSupported': '音频格式不支持,请使用 MP3 或 WAV 格式',
|
||||||
# Rate limit
|
# Rate limit
|
||||||
'RateLimitExceeded': '请求过于频繁,请稍后重试',
|
'RateLimitExceeded': '请求过于频繁,请稍后重试',
|
||||||
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
|
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
|
||||||
@ -41,6 +43,8 @@ _MESSAGE_KEYWORDS = {
|
|||||||
'sensitive': '内容包含敏感信息,请修改后重试',
|
'sensitive': '内容包含敏感信息,请修改后重试',
|
||||||
'not found': '引用的素材不存在或已被删除,请检查素材库',
|
'not found': '引用的素材不存在或已被删除,请检查素材库',
|
||||||
'not valid': '请求参数无效,请检查输入内容',
|
'not valid': '请求参数无效,请检查输入内容',
|
||||||
|
'audio duration': '音频总时长超过15秒限制,请缩短音频后重试',
|
||||||
|
'audio': '音频不符合要求(支持MP3/WAV,单条2-15秒,总时长≤15秒)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,7 @@ def _get_service():
|
|||||||
'GetAssetGroup': ApiInfo('POST', '/', {'Action': 'GetAssetGroup', 'Version': API_VERSION}, {}, {}),
|
'GetAssetGroup': ApiInfo('POST', '/', {'Action': 'GetAssetGroup', 'Version': API_VERSION}, {}, {}),
|
||||||
'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}),
|
'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}),
|
||||||
'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}),
|
'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}),
|
||||||
|
'DeleteAsset': ApiInfo('POST', '/', {'Action': 'DeleteAsset', 'Version': API_VERSION}, {}, {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
return Service(service_info, api_info)
|
return Service(service_info, api_info)
|
||||||
@ -218,3 +219,9 @@ def update_asset(asset_id: str, name: str = None):
|
|||||||
if name is not None:
|
if name is not None:
|
||||||
body['Name'] = name
|
body['Name'] = name
|
||||||
_do_request('UpdateAsset', body)
|
_do_request('UpdateAsset', body)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_asset(asset_id: str):
|
||||||
|
"""Delete a single asset from the remote API."""
|
||||||
|
body = {'Id': asset_id, 'ProjectName': PROJECT_NAME}
|
||||||
|
_do_request('DeleteAsset', body)
|
||||||
|
|||||||
@ -24,6 +24,11 @@ server {
|
|||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SPA client-side routes — must return index.html, not match Vite's dist/assets/ dir
|
||||||
|
location ~ ^/(assets|login|profile|admin|team)(/|$) {
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@ -479,6 +479,13 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom action buttons */}
|
{/* Bottom action buttons */}
|
||||||
|
{isGenerating && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
||||||
|
<EditIcon /> <span>重新编辑</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isGenerating && (
|
{!isGenerating && (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { AssetLibraryModal } from './AssetLibraryModal';
|
|||||||
import { showToast } from './Toast';
|
import { showToast } from './Toast';
|
||||||
import styles from './InputBar.module.css';
|
import styles from './InputBar.module.css';
|
||||||
|
|
||||||
export function InputBar() {
|
export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNode }) {
|
||||||
const mode = useInputBarStore((s) => s.mode);
|
const mode = useInputBarStore((s) => s.mode);
|
||||||
const addReferences = useInputBarStore((s) => s.addReferences);
|
const addReferences = useInputBarStore((s) => s.addReferences);
|
||||||
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
|
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
|
||||||
@ -43,6 +43,16 @@ export function InputBar() {
|
|||||||
|
|
||||||
const valid: File[] = [];
|
const valid: File[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
|
// Format validation
|
||||||
|
if (f.type.startsWith('video/') && f.type !== 'video/mp4' && f.type !== 'video/quicktime') {
|
||||||
|
showToast('仅支持 MP4 和 MOV 格式的视频');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f.type.startsWith('audio/') && f.type !== 'audio/mpeg' && f.type !== 'audio/wav') {
|
||||||
|
showToast('仅支持 MP3 和 WAV 格式的音频');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Size validation
|
||||||
let limit: number;
|
let limit: number;
|
||||||
let limitLabel: string;
|
let limitLabel: string;
|
||||||
if (f.type.startsWith('video/')) {
|
if (f.type.startsWith('video/')) {
|
||||||
@ -134,6 +144,7 @@ export function InputBar() {
|
|||||||
>
|
>
|
||||||
种子值
|
种子值
|
||||||
</button>
|
</button>
|
||||||
|
{scrollBottomBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -3,8 +3,14 @@
|
|||||||
top: 20px;
|
top: 20px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) translateY(-20px);
|
transform: translateX(-50%) translateY(-20px);
|
||||||
background: var(--color-bg-dropdown);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid var(--color-border-input-bar);
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.12) inset;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -13,6 +19,23 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8952e;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show {
|
.show {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export function Toast() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.toast} ${visible ? styles.show : ''}`}>
|
<div className={`${styles.toast} ${visible ? styles.show : ''}`}>
|
||||||
|
<span className={styles.icon}>!</span>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -281,3 +281,28 @@
|
|||||||
background: #1a1a24;
|
background: #1a1a24;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Upload status overlay */
|
||||||
|
.uploadOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: var(--radius-thumbnail);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadError {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,19 @@ import { ImageLightbox } from './ImageLightbox';
|
|||||||
import { tosThumb } from '../lib/api';
|
import { tosThumb } from '../lib/api';
|
||||||
import styles from './UniversalUpload.module.css';
|
import styles from './UniversalUpload.module.css';
|
||||||
|
|
||||||
|
const Spinner = () => (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" className={styles.spinner}>
|
||||||
|
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErrorIcon = () => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="rgba(239,68,68,0.85)" />
|
||||||
|
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="14" fontWeight="bold">!</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
|
||||||
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
|
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
|
||||||
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
|
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
|
||||||
@ -25,6 +38,7 @@ export function UniversalUpload() {
|
|||||||
const references = useInputBarStore((s) => s.references);
|
const references = useInputBarStore((s) => s.references);
|
||||||
const addReferences = useInputBarStore((s) => s.addReferences);
|
const addReferences = useInputBarStore((s) => s.addReferences);
|
||||||
const removeReference = useInputBarStore((s) => s.removeReference);
|
const removeReference = useInputBarStore((s) => s.removeReference);
|
||||||
|
const retryUpload = useInputBarStore((s) => s.retryUpload);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [badgeHover, setBadgeHover] = useState(false);
|
const [badgeHover, setBadgeHover] = useState(false);
|
||||||
@ -40,6 +54,16 @@ export function UniversalUpload() {
|
|||||||
|
|
||||||
const valid: File[] = [];
|
const valid: File[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
|
// Format validation
|
||||||
|
if (f.type.startsWith('video/') && f.type !== 'video/mp4' && f.type !== 'video/quicktime') {
|
||||||
|
showToast('仅支持 MP4 和 MOV 格式的视频');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f.type.startsWith('audio/') && f.type !== 'audio/mpeg' && f.type !== 'audio/wav') {
|
||||||
|
showToast('仅支持 MP3 和 WAV 格式的音频');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Size validation
|
||||||
let limit: number;
|
let limit: number;
|
||||||
let limitLabel: string;
|
let limitLabel: string;
|
||||||
if (f.type.startsWith('video/')) {
|
if (f.type.startsWith('video/')) {
|
||||||
@ -83,7 +107,7 @@ export function UniversalUpload() {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*,audio/*"
|
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
|
||||||
multiple
|
multiple
|
||||||
className={styles.hiddenInput}
|
className={styles.hiddenInput}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
@ -127,6 +151,21 @@ export function UniversalUpload() {
|
|||||||
) : (
|
) : (
|
||||||
<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 */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={styles.thumbClose}
|
className={styles.thumbClose}
|
||||||
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
|
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
|
||||||
|
|||||||
@ -52,3 +52,4 @@
|
|||||||
color: var(--color-text-disabled);
|
color: var(--color-text-disabled);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export function VideoGenerationPage() {
|
|||||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||||
const [autoAnnouncementDone, setAutoAnnouncementDone] = useState(false);
|
const [autoAnnouncementDone, setAutoAnnouncementDone] = useState(false);
|
||||||
|
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||||
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
|
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
|
||||||
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
|
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
|
||||||
|
|
||||||
@ -41,9 +42,10 @@ export function VideoGenerationPage() {
|
|||||||
if (initialLoadRef.current) {
|
if (initialLoadRef.current) {
|
||||||
initialLoadRef.current = false;
|
initialLoadRef.current = false;
|
||||||
// Use requestAnimationFrame to ensure DOM has rendered
|
// Use requestAnimationFrame to ensure DOM has rendered
|
||||||
|
const restoreTop = savedScrollTop;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (savedScrollTop !== null && scrollRef.current) {
|
if (restoreTop !== null && scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = savedScrollTop;
|
scrollRef.current.scrollTop = restoreTop;
|
||||||
} else if (scrollRef.current) {
|
} else if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
@ -55,13 +57,19 @@ export function VideoGenerationPage() {
|
|||||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
prevCountRef.current = tasks.length;
|
prevCountRef.current = tasks.length;
|
||||||
}, [tasks.length, savedScrollTop]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tasks.length]);
|
||||||
|
|
||||||
// Save scroll position + auto-load older tasks when scrolled near top
|
// Save scroll position + auto-load older tasks when scrolled near top
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
saveScrollPosition(scrollRef.current.scrollTop);
|
saveScrollPosition(scrollRef.current.scrollTop);
|
||||||
|
|
||||||
|
// Show "scroll to bottom" button when not near bottom
|
||||||
|
const el = scrollRef.current;
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
setShowScrollBottom(distanceFromBottom > 300);
|
||||||
|
|
||||||
// Trigger loadMore when scrolled within 100px of the top
|
// Trigger loadMore when scrolled within 100px of the top
|
||||||
if (scrollRef.current.scrollTop < 100) {
|
if (scrollRef.current.scrollTop < 100) {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@ -166,7 +174,26 @@ export function VideoGenerationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InputBar />
|
<InputBar scrollBottomBtn={showScrollBottom ? (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'rgba(255, 255, 255, 0.06)',
|
||||||
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.10)',
|
||||||
|
boxShadow: '0 0 0 1px rgba(255,255,255,0.05) inset, 0 4px 16px rgba(0,0,0,0.3)',
|
||||||
|
borderRadius: 6, padding: '4px 12px', fontSize: 12,
|
||||||
|
color: 'var(--color-text-secondary)', cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.10)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-primary)'; }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.06)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
|
||||||
|
>
|
||||||
|
回到底部 ↓
|
||||||
|
</button>
|
||||||
|
) : null} />
|
||||||
</main>
|
</main>
|
||||||
<VideoDetailModal
|
<VideoDetailModal
|
||||||
task={detailTask}
|
task={detailTask}
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
--radius-thumbnail: 8px;
|
--radius-thumbnail: 8px;
|
||||||
--radius-dropdown: 12px;
|
--radius-dropdown: 12px;
|
||||||
|
|
||||||
--input-bar-max-width: 900px;
|
--input-bar-max-width: 950px;
|
||||||
--send-btn-size: 36px;
|
--send-btn-size: 36px;
|
||||||
--thumbnail-size: 80px;
|
--thumbnail-size: 80px;
|
||||||
--toolbar-height: 44px;
|
--toolbar-height: 44px;
|
||||||
|
|||||||
@ -129,7 +129,7 @@ export const mediaApi = {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return api.post<{
|
return api.post<{
|
||||||
url: string;
|
url: string;
|
||||||
type: 'image' | 'video';
|
type: 'image' | 'video' | 'audio';
|
||||||
filename: string;
|
filename: string;
|
||||||
size: number;
|
size: number;
|
||||||
}>('/media/upload', formData, {
|
}>('/media/upload', formData, {
|
||||||
|
|||||||
@ -88,6 +88,9 @@ function VideoThumbnail({
|
|||||||
export function AssetsPage() {
|
export function AssetsPage() {
|
||||||
const tasks = useGenerationStore((s) => s.tasks);
|
const tasks = useGenerationStore((s) => s.tasks);
|
||||||
const loadTasks = useGenerationStore((s) => s.loadTasks);
|
const loadTasks = useGenerationStore((s) => s.loadTasks);
|
||||||
|
const loadMore = useGenerationStore((s) => s.loadMore);
|
||||||
|
const hasMore = useGenerationStore((s) => s.hasMore);
|
||||||
|
const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
|
||||||
const reEdit = useGenerationStore((s) => s.reEdit);
|
const reEdit = useGenerationStore((s) => s.reEdit);
|
||||||
const regenerate = useGenerationStore((s) => s.regenerate);
|
const regenerate = useGenerationStore((s) => s.regenerate);
|
||||||
const removeTask = useGenerationStore((s) => s.removeTask);
|
const removeTask = useGenerationStore((s) => s.removeTask);
|
||||||
@ -102,8 +105,9 @@ export function AssetsPage() {
|
|||||||
|
|
||||||
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
|
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
|
||||||
|
|
||||||
|
// Reverse: newest first for asset page (store keeps oldest-first for generation page)
|
||||||
const completedTasks = useMemo(
|
const completedTasks = useMemo(
|
||||||
() => tasks.filter((t) => t.status === 'completed'),
|
() => tasks.filter((t) => t.status === 'completed').slice().reverse(),
|
||||||
[tasks],
|
[tasks],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -160,7 +164,8 @@ export function AssetsPage() {
|
|||||||
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
|
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dateGroups.map((group) => (
|
<>
|
||||||
|
{dateGroups.map((group) => (
|
||||||
<section key={group.label} className={styles.dateGroup}>
|
<section key={group.label} className={styles.dateGroup}>
|
||||||
<h3 className={styles.dateLabel}>{group.label}</h3>
|
<h3 className={styles.dateLabel}>{group.label}</h3>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
@ -173,7 +178,27 @@ export function AssetsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 32px',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? '加载中...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -409,13 +409,14 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload files to TOS (or reuse existing TOS URLs)
|
// Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed
|
||||||
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
|
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
|
||||||
|
|
||||||
for (const item of filesToUpload) {
|
for (const item of filesToUpload) {
|
||||||
if (item.tosUrl) {
|
if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
|
||||||
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
|
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
|
||||||
} else if (item.file) {
|
} else if (item.file) {
|
||||||
|
// Fallback: file wasn't pre-uploaded (shouldn't normally happen with immediate upload)
|
||||||
const { data: uploadResult } = await mediaApi.upload(item.file);
|
const { data: uploadResult } = await mediaApi.upload(item.file);
|
||||||
uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label });
|
uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,64 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
|
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
|
import { mediaApi } from '../lib/api';
|
||||||
|
|
||||||
let fileCounter = 0;
|
let fileCounter = 0;
|
||||||
|
|
||||||
|
/** Read image dimensions via HTML5 Image element. */
|
||||||
|
function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const cleanup = () => URL.revokeObjectURL(url);
|
||||||
|
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
|
||||||
|
img.onload = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('无法读取图片'));
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read duration (and dimensions for video) of an audio or video file. */
|
||||||
|
interface MediaInfo {
|
||||||
|
duration: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaInfo(file: File): Promise<MediaInfo> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const isAudio = file.type.startsWith('audio/');
|
||||||
|
const el = isAudio ? new Audio() : document.createElement('video');
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const cleanup = () => URL.revokeObjectURL(url);
|
||||||
|
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
|
||||||
|
el.addEventListener('loadedmetadata', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (isAudio) {
|
||||||
|
resolve({ duration: el.duration });
|
||||||
|
} else {
|
||||||
|
const vid = el as HTMLVideoElement;
|
||||||
|
resolve({ duration: vid.duration, width: vid.videoWidth, height: vid.videoHeight });
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
el.addEventListener('error', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('无法读取媒体文件'));
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
el.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// API limits per Seedance 2.0 official doc
|
// API limits per Seedance 2.0 official doc
|
||||||
const MAX_IMAGES = 9;
|
const MAX_IMAGES = 9;
|
||||||
const MAX_VIDEOS = 3;
|
const MAX_VIDEOS = 3;
|
||||||
@ -46,6 +101,7 @@ interface InputBarState {
|
|||||||
addReferences: (files: File[]) => void;
|
addReferences: (files: File[]) => void;
|
||||||
removeReference: (id: string) => void;
|
removeReference: (id: string) => void;
|
||||||
clearReferences: () => void;
|
clearReferences: () => void;
|
||||||
|
retryUpload: (refId: string) => void;
|
||||||
|
|
||||||
// Keyframe
|
// Keyframe
|
||||||
firstFrame: UploadedFile | null;
|
firstFrame: UploadedFile | null;
|
||||||
@ -118,7 +174,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
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 newRefs: UploadedFile[] = [];
|
// Separate images (sync) from audio/video (need async duration check)
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
const mediaFiles: File[] = []; // audio + video
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const type: 'image' | 'video' | 'audio' = file.type.startsWith('video/')
|
const type: 'image' | 'video' | 'audio' = file.type.startsWith('video/')
|
||||||
? 'video'
|
? 'video'
|
||||||
@ -132,24 +191,23 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
showToast(label);
|
showToast(label);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
fileCounter++;
|
|
||||||
const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片';
|
|
||||||
// 编号接着已有的同类型素材(包括 @ 引用的 assetMentions)
|
|
||||||
const existingSameType = state.references.filter(r => r.type === type).length
|
|
||||||
+ newRefs.filter(r => r.type === type).length
|
|
||||||
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === type).length;
|
|
||||||
newRefs.push({
|
|
||||||
id: `ref_${fileCounter}`,
|
|
||||||
file,
|
|
||||||
type,
|
|
||||||
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
|
|
||||||
label: `${labelPrefix}${existingSameType + 1}`,
|
|
||||||
});
|
|
||||||
counts[type]++;
|
counts[type]++;
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
imageFiles.push(file);
|
||||||
|
} else {
|
||||||
|
mediaFiles.push(file);
|
||||||
}
|
}
|
||||||
if (newRefs.length > 0) {
|
}
|
||||||
set({ references: [...state.references, ...newRefs] });
|
|
||||||
|
// ── Async: validate image dimensions, then add + upload ──
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
_validateAndAddImages(imageFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async: validate audio/video duration, then add + upload ──
|
||||||
|
if (mediaFiles.length > 0) {
|
||||||
|
_validateAndAddMedia(mediaFiles);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeReference: (id) => {
|
removeReference: (id) => {
|
||||||
@ -163,6 +221,16 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
|
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
|
||||||
set({ references: [] });
|
set({ references: [] });
|
||||||
},
|
},
|
||||||
|
retryUpload: (refId) => {
|
||||||
|
const ref = get().references.find((r) => r.id === refId);
|
||||||
|
if (!ref?.file) return;
|
||||||
|
set({
|
||||||
|
references: get().references.map((r) =>
|
||||||
|
r.id === refId ? { ...r, uploading: true, uploadError: false } : r
|
||||||
|
),
|
||||||
|
});
|
||||||
|
_uploadRef(refId, ref.file);
|
||||||
|
},
|
||||||
|
|
||||||
firstFrame: null,
|
firstFrame: null,
|
||||||
lastFrame: null,
|
lastFrame: null,
|
||||||
@ -216,6 +284,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video');
|
const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video');
|
||||||
if (!hasImageOrVideo && !hasText) return false;
|
if (!hasImageOrVideo && !hasText) return false;
|
||||||
}
|
}
|
||||||
|
// Block submit if any reference is still uploading or failed
|
||||||
|
if (state.references.some((r) => r.uploading || r.uploadError)) return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -290,3 +360,181 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── Module-level helpers (use useInputBarStore late-binding) ──
|
||||||
|
|
||||||
|
/** Upload a single reference file to TOS and update the store. */
|
||||||
|
function _uploadRef(refId: string, file: File) {
|
||||||
|
mediaApi.upload(file).then(({ data }) => {
|
||||||
|
useInputBarStore.setState((s) => ({
|
||||||
|
references: s.references.map((r) =>
|
||||||
|
r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}).catch(() => {
|
||||||
|
useInputBarStore.setState((s) => ({
|
||||||
|
references: s.references.map((r) =>
|
||||||
|
r.id === refId ? { ...r, uploading: false, uploadError: true } : r
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate image dimensions per Seedance API spec, then add to store + start upload. */
|
||||||
|
async function _validateAndAddImages(files: File[]) {
|
||||||
|
for (const file of files) {
|
||||||
|
// Read dimensions
|
||||||
|
let dims: { width: number; height: number };
|
||||||
|
try {
|
||||||
|
dims = await getImageDimensions(file);
|
||||||
|
} catch {
|
||||||
|
showToast('无法读取图片信息');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = dims;
|
||||||
|
// API spec: width/height in open interval (300, 6000)
|
||||||
|
if (width >= 6000 || height >= 6000) {
|
||||||
|
showToast(`图片尺寸过大(${width}×${height}),宽高需小于 6000 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (width <= 300 || height <= 300) {
|
||||||
|
showToast(`图片尺寸过小(${width}×${height}),宽高需大于 300 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// API spec: aspect ratio (width/height) in open interval (0.4, 2.5)
|
||||||
|
const ratio = width / height;
|
||||||
|
if (ratio <= 0.4 || ratio >= 2.5) {
|
||||||
|
showToast(`图片比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check count
|
||||||
|
const state = useInputBarStore.getState();
|
||||||
|
const currentCount = state.references.filter((r) => r.type === 'image').length;
|
||||||
|
if (currentCount >= MAX_IMAGES) {
|
||||||
|
showToast(`最多上传${MAX_IMAGES}张图片`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passed — add to store + upload
|
||||||
|
fileCounter++;
|
||||||
|
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 newRef: UploadedFile = {
|
||||||
|
id: refId,
|
||||||
|
file,
|
||||||
|
type: 'image',
|
||||||
|
previewUrl: URL.createObjectURL(file),
|
||||||
|
label: `图片${existingSameType + 1}`,
|
||||||
|
uploading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
useInputBarStore.setState((s) => ({
|
||||||
|
references: [...s.references, newRef],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_uploadRef(refId, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MEDIA_DURATION = 15; // seconds per item and total
|
||||||
|
|
||||||
|
/** Validate audio/video duration (+ video dimensions), then add to store + start upload. */
|
||||||
|
async function _validateAndAddMedia(files: File[]) {
|
||||||
|
for (const file of files) {
|
||||||
|
const type: 'video' | 'audio' = file.type.startsWith('video/') ? 'video' : 'audio';
|
||||||
|
const typeLabel = type === 'video' ? '视频' : '音频';
|
||||||
|
|
||||||
|
// Read duration (+ dimensions for video)
|
||||||
|
let info: MediaInfo;
|
||||||
|
try {
|
||||||
|
info = await getMediaInfo(file);
|
||||||
|
} catch {
|
||||||
|
showToast(`无法读取${typeLabel}文件信息`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = info.duration;
|
||||||
|
|
||||||
|
// Single item duration check
|
||||||
|
// API specifies [2, 15]s — lower bound strict, upper bound +0.4s for codec imprecision
|
||||||
|
if (dur < 2) {
|
||||||
|
showToast(`${typeLabel}时长不能少于2秒`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dur > MAX_MEDIA_DURATION + 0.4) {
|
||||||
|
showToast(`单条${typeLabel}时长不能超过${MAX_MEDIA_DURATION}秒`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video dimension checks — API spec: [300, 6000]px, ratio [0.4, 2.5], pixels [409600, 927408]
|
||||||
|
if (type === 'video' && info.width && info.height) {
|
||||||
|
const { width, height } = info;
|
||||||
|
if (width > 6000 || height > 6000) {
|
||||||
|
showToast(`视频尺寸过大(${width}×${height}),宽高不能超过 6000 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (width < 300 || height < 300) {
|
||||||
|
showToast(`视频尺寸过小(${width}×${height}),宽高不能小于 300 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ratio = width / height;
|
||||||
|
if (ratio < 0.4 || ratio > 2.5) {
|
||||||
|
showToast(`视频比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pixels = width * height;
|
||||||
|
if (pixels < 409600) {
|
||||||
|
showToast(`视频像素过低(${width}×${height}=${pixels.toLocaleString()}),最低需 409,600 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pixels > 927408) {
|
||||||
|
showToast(`视频像素过高(${width}×${height}=${pixels.toLocaleString()}),最高 927,408 像素`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total duration check (same type)
|
||||||
|
const state = useInputBarStore.getState();
|
||||||
|
const existingDuration = state.references
|
||||||
|
.filter((r) => r.type === type && r.duration)
|
||||||
|
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||||
|
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
|
||||||
|
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}秒`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check count (may have changed since initial check)
|
||||||
|
const currentCount = state.references.filter((r) => r.type === type).length;
|
||||||
|
const max = type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
|
||||||
|
if (currentCount >= max) {
|
||||||
|
showToast(`最多上传${max}个${typeLabel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, string>) => m.type === type).length;
|
||||||
|
const refId = `ref_${fileCounter}`;
|
||||||
|
const newRef: UploadedFile = {
|
||||||
|
id: refId,
|
||||||
|
file,
|
||||||
|
type,
|
||||||
|
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
|
||||||
|
label: `${labelPrefix}${existingSameType + 1}`,
|
||||||
|
uploading: true,
|
||||||
|
duration: Math.round(dur * 10) / 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
useInputBarStore.setState((s) => ({
|
||||||
|
references: [...s.references, newRef],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Start upload
|
||||||
|
_uploadRef(refId, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ export interface UploadedFile {
|
|||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
label: string;
|
label: string;
|
||||||
tosUrl?: string; // TOS URL after upload
|
tosUrl?: string; // TOS URL after upload
|
||||||
|
uploading?: boolean;
|
||||||
|
uploadError?: boolean;
|
||||||
|
duration?: number; // media duration in seconds (audio/video only)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropdownOption<T = string> {
|
export interface DropdownOption<T = string> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user