Sync latest TrueGrowth updates
This commit is contained in:
@@ -17,7 +17,10 @@ import { createPortal } from 'react-dom';
|
||||
import { Input } from 'tdesign-react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import {
|
||||
DEFAULT_AUDIO_MODEL_ID,
|
||||
DEFAULT_IMAGE_MODEL_ID,
|
||||
DEFAULT_TEXT_MODEL_ID,
|
||||
DEFAULT_VIDEO_MODEL_ID,
|
||||
type ModelConfig,
|
||||
type ModelType,
|
||||
type ModelVendor,
|
||||
@@ -51,6 +54,8 @@ export interface ModelSelectorProps {
|
||||
variant?: 'capsule' | 'form';
|
||||
/** Which runtime model family to show. Defaults to text for existing callers. */
|
||||
modelType?: ModelType;
|
||||
/** Optional caller-provided model list, used when a page already resolved models. */
|
||||
modelsOverride?: ModelConfig[];
|
||||
}
|
||||
|
||||
function getProviderLabel(model: ModelConfig): string {
|
||||
@@ -100,6 +105,13 @@ function getItemInitial(model: ModelConfig): string {
|
||||
return getProviderLabel(model).trim().slice(0, 1).toUpperCase() || 'M';
|
||||
}
|
||||
|
||||
function getDefaultModelIdForType(modelType: ModelType): string {
|
||||
if (modelType === 'image') return DEFAULT_IMAGE_MODEL_ID;
|
||||
if (modelType === 'video') return DEFAULT_VIDEO_MODEL_ID;
|
||||
if (modelType === 'audio') return DEFAULT_AUDIO_MODEL_ID;
|
||||
return DEFAULT_TEXT_MODEL_ID;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
({
|
||||
className,
|
||||
@@ -108,9 +120,12 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
onChange,
|
||||
variant = 'capsule',
|
||||
modelType = 'text',
|
||||
modelsOverride,
|
||||
}) => {
|
||||
const baseSelectableModels = useSelectableModels(modelType);
|
||||
const defaultModelId = baseSelectableModels[0]?.id || DEFAULT_TEXT_MODEL_ID;
|
||||
const resolvedSelectableModels = modelsOverride || baseSelectableModels;
|
||||
const defaultModelId =
|
||||
resolvedSelectableModels[0]?.id || getDefaultModelIdForType(modelType);
|
||||
const [internalModel, setInternalModel] = useState<string>(defaultModelId);
|
||||
const [internalModelRef, setInternalModelRef] = useState<ModelRef | null>(
|
||||
() => createModelRef(null, defaultModelId)
|
||||
@@ -165,7 +180,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
return getSelectionKey(selectedModel, selectedModelRef);
|
||||
}, [selectedModel, selectedModelRef]);
|
||||
|
||||
const selectableModels = baseSelectableModels;
|
||||
const selectableModels = resolvedSelectableModels;
|
||||
|
||||
const currentModel = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -138,22 +138,25 @@ export const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLProps<HTMLDivElement> & { container?: HTMLElement | null }
|
||||
>(function DialogContent(props, propRef) {
|
||||
const { container, children, ...contentProps } = props;
|
||||
const { context: floatingContext, ...context } = useDialogContext();
|
||||
const ref = useMergeRefs([context.refs.setFloating, propRef]);
|
||||
|
||||
if (!floatingContext.open) return null;
|
||||
|
||||
const portalProps = container ? { root: container } : {};
|
||||
|
||||
return (
|
||||
<FloatingPortal root={props.container}>
|
||||
<FloatingPortal {...portalProps}>
|
||||
<FloatingOverlay className="Dialog-overlay" lockScroll>
|
||||
<FloatingFocusManager context={floatingContext}>
|
||||
<div
|
||||
ref={ref}
|
||||
aria-labelledby={context.labelId}
|
||||
aria-describedby={context.descriptionId}
|
||||
{...context.getFloatingProps(props)}
|
||||
{...context.getFloatingProps(contentProps)}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</div>
|
||||
</FloatingFocusManager>
|
||||
</FloatingOverlay>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,30 +375,35 @@
|
||||
&__list-actions {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
top: 10px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--aitu-ui-border-subtle, rgba(15, 23, 42, 0.06));
|
||||
border-radius: 12px;
|
||||
background: var(--aitu-ui-surface-elevated, rgba(255, 255, 255, 0.92));
|
||||
box-shadow: var(--aitu-ui-shadow-soft, 0 2px 10px rgba(15, 23, 42, 0.06));
|
||||
backdrop-filter: blur(14px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
&__quick-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 18, 0.54);
|
||||
color: #ffffff;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--aitu-ui-text-secondary, #64748b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: background-color 0.16s ease, border-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
|
||||
@@ -408,14 +413,15 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 91, 0, 0.72);
|
||||
background: rgba(255, 91, 0, 0.92);
|
||||
color: #ffffff;
|
||||
border-color: var(--aitu-ui-brand-border-soft, rgba(255, 91, 0, 0.14));
|
||||
background: var(--aitu-ui-brand-soft, rgba(255, 91, 0, 0.1));
|
||||
color: var(--aitu-ui-brand-text, #e14f00);
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
border-color: rgba(239, 68, 68, 0.78);
|
||||
background: rgba(239, 68, 68, 0.92);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,8 +648,9 @@
|
||||
|
||||
.asset-item__quick-actions {
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
top: 6px;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.asset-item__quick-action {
|
||||
@@ -845,3 +852,28 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-tg-theme='dark'] .asset-item {
|
||||
&__quick-actions,
|
||||
&__list-actions {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(18, 18, 18, 0.82);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
&__quick-action {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 91, 0, 0.38);
|
||||
background: rgba(255, 91, 0, 0.16);
|
||||
color: #ff8a3d;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
border-color: rgba(239, 68, 68, 0.44);
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #ff8a8a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,23 @@ vi.mock('tdesign-react', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../lazy-image', () => ({
|
||||
LazyImage: ({ src, alt, className }: any) => (
|
||||
<img src={src} alt={alt} className={className} />
|
||||
LazyImage: ({ src, alt, className, objectFit }: any) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
data-object-fit={objectFit}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/VideoPosterPreview', () => ({
|
||||
VideoPosterPreview: ({ src, poster, alt, className }: any) => (
|
||||
VideoPosterPreview: ({ src, poster, alt, className, fit }: any) => (
|
||||
<img
|
||||
src={poster || src}
|
||||
alt={alt}
|
||||
className={`video-poster-preview__media ${className || ''}`}
|
||||
data-fit={fit}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -66,6 +72,15 @@ const videoAsset: Asset = {
|
||||
mimeType: 'video/mp4',
|
||||
};
|
||||
|
||||
const imageAsset: Asset = {
|
||||
...baseAsset,
|
||||
id: 'image-asset',
|
||||
type: AssetType.IMAGE,
|
||||
url: '/media/demo.jpg',
|
||||
name: '图片素材',
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const audioAsset: Asset = {
|
||||
...baseAsset,
|
||||
id: 'audio-asset',
|
||||
@@ -76,13 +91,17 @@ const audioAsset: Asset = {
|
||||
mimeType: 'audio/mpeg',
|
||||
};
|
||||
|
||||
function renderAssetItem(asset: Asset) {
|
||||
function renderAssetItem(
|
||||
asset: Asset,
|
||||
props: Partial<React.ComponentProps<typeof AssetItem>> = {}
|
||||
) {
|
||||
return render(
|
||||
<AssetItem
|
||||
asset={asset}
|
||||
viewMode="grid"
|
||||
isSelected={false}
|
||||
onSelect={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -155,4 +174,44 @@ describe('AssetItem hover preview', () => {
|
||||
});
|
||||
expect(screen.getByAltText(audioAsset.name)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not start video or audio hover playback when disabled', async () => {
|
||||
const { container, rerender } = renderAssetItem(videoAsset, {
|
||||
disableHoverPlayback: true,
|
||||
});
|
||||
const videoItem = container.querySelector('.asset-item') as HTMLElement;
|
||||
|
||||
fireEvent.mouseEnter(videoItem);
|
||||
|
||||
expect(container.querySelector('.asset-item__hover-video')).toBeNull();
|
||||
expect(window.HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<AssetItem
|
||||
asset={audioAsset}
|
||||
viewMode="grid"
|
||||
isSelected={false}
|
||||
onSelect={vi.fn()}
|
||||
disableHoverPlayback
|
||||
/>
|
||||
);
|
||||
const audioItem = container.querySelector('.asset-item') as HTMLElement;
|
||||
|
||||
fireEvent.mouseEnter(audioItem);
|
||||
|
||||
expect(container.querySelector('.asset-item__hover-audio')).toBeNull();
|
||||
expect(window.HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes cover fit to image and video thumbnails when requested', () => {
|
||||
renderAssetItem(imageAsset, { thumbnailFit: 'cover' });
|
||||
expect(
|
||||
screen.getByAltText(imageAsset.name).getAttribute('data-object-fit')
|
||||
).toBe('cover');
|
||||
|
||||
renderAssetItem(videoAsset, { thumbnailFit: 'cover' });
|
||||
expect(screen.getByAltText(videoAsset.name).getAttribute('data-fit')).toBe(
|
||||
'cover'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface AssetItemProps {
|
||||
isFavorite?: boolean;
|
||||
onToggleFavorite?: (asset: Asset, event: React.MouseEvent) => void;
|
||||
previewOnClick?: boolean;
|
||||
disableHoverPlayback?: boolean;
|
||||
thumbnailFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
export const AssetItem = memo<AssetItemProps>(
|
||||
@@ -67,6 +69,8 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
previewOnClick,
|
||||
disableHoverPlayback,
|
||||
thumbnailFit = 'contain',
|
||||
}) => {
|
||||
// 获取实际文件大小(支持从缓存获取)
|
||||
const displaySize = useAssetSize(asset.id, asset.url, asset.size);
|
||||
@@ -197,6 +201,7 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
const isSubjectAsset = asset.category === AssetCategory.CHARACTER;
|
||||
const isPublishableAsset = asset.type === 'IMAGE' || asset.type === 'VIDEO';
|
||||
const canHoverPreview =
|
||||
!disableHoverPlayback &&
|
||||
!isInSelectionMode &&
|
||||
Boolean(asset.url) &&
|
||||
(asset.type === 'VIDEO' || asset.type === 'AUDIO');
|
||||
@@ -312,7 +317,7 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
src={asset.thumbnail}
|
||||
alt={asset.name}
|
||||
className="asset-item__image"
|
||||
objectFit="cover"
|
||||
objectFit={thumbnailFit}
|
||||
rootMargin="100px"
|
||||
/>
|
||||
) : (
|
||||
@@ -325,7 +330,7 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
src={imagePreviewSrc || asset.url}
|
||||
alt={asset.name}
|
||||
className="asset-item__image"
|
||||
objectFit="cover"
|
||||
objectFit={thumbnailFit}
|
||||
rootMargin="100px"
|
||||
onError={() => {
|
||||
if (imagePreviewSrc && imagePreviewSrc !== asset.url) {
|
||||
@@ -339,6 +344,7 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
className="asset-item__video"
|
||||
alt={asset.name}
|
||||
poster={asset.thumbnail}
|
||||
fit={thumbnailFit}
|
||||
thumbnailSize={thumbnailSize}
|
||||
videoProps={{
|
||||
muted: true,
|
||||
@@ -667,7 +673,9 @@ export const AssetItem = memo<AssetItemProps>(
|
||||
prevProps.onDelete === nextProps.onDelete &&
|
||||
prevProps.isSynced === nextProps.isSynced &&
|
||||
prevProps.isFavorite === nextProps.isFavorite &&
|
||||
prevProps.previewOnClick === nextProps.previewOnClick
|
||||
prevProps.previewOnClick === nextProps.previewOnClick &&
|
||||
prevProps.disableHoverPlayback === nextProps.disableHoverPlayback &&
|
||||
prevProps.thumbnailFit === nextProps.thumbnailFit
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -333,11 +333,15 @@ export function MediaLibraryGrid({
|
||||
onPublishAsset,
|
||||
onFileUpload,
|
||||
onUploadClick,
|
||||
onDeleteAsset,
|
||||
storageStatus,
|
||||
onSelectionChange,
|
||||
previewOnClick = false,
|
||||
disableHoverPlayback = false,
|
||||
gridAspectRatio,
|
||||
thumbnailFit = 'contain',
|
||||
extraAssets = [],
|
||||
defaultSelectionModeActive = false,
|
||||
}: MediaLibraryGridProps) {
|
||||
const {
|
||||
assets,
|
||||
@@ -365,7 +369,9 @@ export function MediaLibraryGrid({
|
||||
const { board } = useDrawnix();
|
||||
const { isMobile } = useDeviceType();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(
|
||||
defaultSelectionModeActive
|
||||
);
|
||||
const [selectionState, setSelectionState] = useState<SelectionState>(() =>
|
||||
createEmptySelectionState()
|
||||
);
|
||||
@@ -373,6 +379,13 @@ export function MediaLibraryGrid({
|
||||
const [emptyLoadingExpired, setEmptyLoadingExpired] = useState(false);
|
||||
const lastSelectedIdRef = useRef<string | null>(null); // 记录上次选中的资产 ID,用于 Shift 连选
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultSelectionModeActive) {
|
||||
return;
|
||||
}
|
||||
setIsSelectionMode(true);
|
||||
}, [defaultSelectionModeActive]);
|
||||
|
||||
// 预览状态
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewItems, setPreviewItems] = useState<UnifiedMediaItem[]>([]);
|
||||
@@ -592,6 +605,13 @@ export function MediaLibraryGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
if (onDeleteAsset) {
|
||||
const handled = await onDeleteAsset(asset);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (board) {
|
||||
if (isCacheUrl(asset.url)) {
|
||||
removeElementsByAssetUrls(board, asset.dedupeUrls || [asset.url]);
|
||||
@@ -601,7 +621,7 @@ export function MediaLibraryGrid({
|
||||
}
|
||||
await removeAssets([asset.id]);
|
||||
},
|
||||
[board, confirm, removeAssets]
|
||||
[board, confirm, onDeleteAsset, removeAssets]
|
||||
);
|
||||
|
||||
const assetContextMenuItems = useMemo<ContextMenuEntry<Asset>[]>(
|
||||
@@ -1394,7 +1414,9 @@ export function MediaLibraryGrid({
|
||||
{
|
||||
type: item.type,
|
||||
content:
|
||||
item.type === 'image' ? normalizeImageDataUrl(item.url) : item.url,
|
||||
item.type === 'image'
|
||||
? normalizeImageDataUrl(item.url)
|
||||
: item.url,
|
||||
metadata:
|
||||
item.type === 'audio'
|
||||
? {
|
||||
@@ -1428,40 +1450,37 @@ export function MediaLibraryGrid({
|
||||
}, []);
|
||||
|
||||
// 编辑后插入画布
|
||||
const handleEditInsert = useCallback(
|
||||
async (editedImageUrl: string) => {
|
||||
try {
|
||||
// 导入必要服务
|
||||
const { unifiedCacheService } = await import(
|
||||
'../../services/unified-cache-service'
|
||||
);
|
||||
const handleEditInsert = useCallback(async (editedImageUrl: string) => {
|
||||
try {
|
||||
// 导入必要服务
|
||||
const { unifiedCacheService } = await import(
|
||||
'../../services/unified-cache-service'
|
||||
);
|
||||
|
||||
const taskId = `edited-image-${Date.now()}`;
|
||||
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
|
||||
const taskId = `edited-image-${Date.now()}`;
|
||||
const stableUrl = `/__aitu_cache__/image/${taskId}.png`;
|
||||
|
||||
// 将 data URL 转换为 Blob
|
||||
const response = await fetch(editedImageUrl);
|
||||
const blob = await response.blob();
|
||||
// 将 data URL 转换为 Blob
|
||||
const response = await fetch(editedImageUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
// 缓存到 Cache API
|
||||
await unifiedCacheService.cacheMediaFromBlob(stableUrl, blob, 'image', {
|
||||
taskId,
|
||||
});
|
||||
// 缓存到 Cache API
|
||||
await unifiedCacheService.cacheMediaFromBlob(stableUrl, blob, 'image', {
|
||||
taskId,
|
||||
});
|
||||
|
||||
// 插入到画布
|
||||
await executeCanvasInsertion({
|
||||
items: [{ type: 'image', content: stableUrl }],
|
||||
});
|
||||
// 插入到画布
|
||||
await executeCanvasInsertion({
|
||||
items: [{ type: 'image', content: stableUrl }],
|
||||
});
|
||||
|
||||
// 关闭编辑器
|
||||
setImageEditorVisible(false);
|
||||
setImageEditorUrl('');
|
||||
} catch (error) {
|
||||
console.error('Failed to insert edited image:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
// 关闭编辑器
|
||||
setImageEditorVisible(false);
|
||||
setImageEditorUrl('');
|
||||
} catch (error) {
|
||||
console.error('Failed to insert edited image:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 键盘事件处理(空格键/回车键预览选中的素材)
|
||||
useEffect(() => {
|
||||
@@ -1932,7 +1951,9 @@ export function MediaLibraryGrid({
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
syncedUrls={syncedUrls}
|
||||
previewOnClick={previewOnClick}
|
||||
disableHoverPlayback={disableHoverPlayback}
|
||||
gridAspectRatio={gridAspectRatio}
|
||||
thumbnailFit={thumbnailFit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { useDrawnix } from '../../hooks/use-drawnix';
|
||||
import { ConfirmDialog } from '../dialog/ConfirmDialog';
|
||||
import { RetryImage } from '../retry-image';
|
||||
import { VideoPosterPreview } from '../shared/VideoPosterPreview';
|
||||
import { HoverTip } from '../shared/hover';
|
||||
import type { MediaLibraryInspectorProps } from '../../types/asset.types';
|
||||
import { AssetCategory, AssetType } from '../../types/asset.types';
|
||||
@@ -73,6 +72,9 @@ export function MediaLibraryInspector({
|
||||
showSelectButton,
|
||||
selecting = false,
|
||||
selectButtonText = '使用',
|
||||
batchSelectButtonText,
|
||||
selectionModeTitle = '已进入多选模式',
|
||||
selectionModeDescription = '右侧操作会应用到当前筛选结果中已勾选的资产。',
|
||||
}: MediaLibraryInspectorProps) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -266,10 +268,10 @@ export function MediaLibraryInspector({
|
||||
批量选择
|
||||
</span>
|
||||
<h3 className="media-library-inspector__selection-title">
|
||||
已选 {selectedAssetCount} 个资产
|
||||
{selectionModeTitle}
|
||||
</h3>
|
||||
<p className="media-library-inspector__selection-desc">
|
||||
右侧操作会应用到当前筛选结果中已勾选的资产。
|
||||
{selectionModeDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +317,7 @@ export function MediaLibraryInspector({
|
||||
data-track="inspector_batch_use_assets"
|
||||
className="inspector-btn-select"
|
||||
>
|
||||
{selectButtonText}
|
||||
{batchSelectButtonText || selectButtonText}
|
||||
{selectedAssetCount > 0 ? ` (${selectedAssetCount})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
@@ -377,18 +379,16 @@ export function MediaLibraryInspector({
|
||||
eager
|
||||
/>
|
||||
) : (
|
||||
<VideoPosterPreview
|
||||
<video
|
||||
src={normalizedAssetUrl}
|
||||
alt={asset.name}
|
||||
className="media-library-inspector__video"
|
||||
poster={asset.thumbnail}
|
||||
thumbnailSize="large"
|
||||
activateVideoOnClick
|
||||
playOnActivate
|
||||
videoProps={{
|
||||
controls: true,
|
||||
preload: 'metadata',
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -237,6 +237,200 @@
|
||||
}
|
||||
}
|
||||
|
||||
.media-library-layout--picker {
|
||||
.asset-item--grid,
|
||||
.asset-item--compact {
|
||||
border-radius: 14px;
|
||||
background: var(--aitu-ui-surface, #ffffff);
|
||||
}
|
||||
|
||||
.asset-item__thumbnail {
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.045);
|
||||
}
|
||||
|
||||
.asset-item__image,
|
||||
.asset-item__video,
|
||||
.asset-item__hover-video,
|
||||
.asset-item__image :is(.lazy-image__img, img),
|
||||
.asset-item__video :is(.video-poster-preview__media, img, video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-item__overlay {
|
||||
height: 54%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.64) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.asset-item__name-overlay {
|
||||
right: 16px;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.asset-item__badges {
|
||||
top: 10px;
|
||||
left: auto;
|
||||
right: 10px;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.asset-item__type-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 18, 0.48);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
|
||||
svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.asset-item__ai-badge,
|
||||
.asset-item__subject-badge,
|
||||
.asset-item__synced-badge,
|
||||
.asset-item__cache-warning-badge,
|
||||
.asset-item__favorite-btn {
|
||||
border: 0;
|
||||
background: rgba(18, 18, 18, 0.52);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.asset-item__quick-actions {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.asset-item:hover .asset-item__quick-actions,
|
||||
.asset-item:focus-within .asset-item__quick-actions {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.asset-item__quick-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(18, 18, 18, 0.58);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.22);
|
||||
backdrop-filter: blur(14px);
|
||||
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: transparent;
|
||||
background: rgba(255, 91, 0, 0.94);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.92);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-item--compact {
|
||||
.asset-item__badges {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.asset-item__type-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-item__quick-actions {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.asset-item__quick-action {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-tg-theme='dark'] .media-library-layout--picker {
|
||||
.asset-item--grid,
|
||||
.asset-item--compact {
|
||||
background: var(--aitu-ui-surface, #0f0f12);
|
||||
}
|
||||
|
||||
.asset-item__thumbnail {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.asset-item__type-badge,
|
||||
.asset-item__ai-badge,
|
||||
.asset-item__subject-badge,
|
||||
.asset-item__synced-badge,
|
||||
.asset-item__cache-warning-badge,
|
||||
.asset-item__favorite-btn {
|
||||
background: rgba(10, 10, 12, 0.62);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.asset-item__quick-actions {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.asset-item__quick-action {
|
||||
background: rgba(10, 10, 12, 0.66);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 91, 0, 0.92);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 移动端详情抽屉
|
||||
// ========================================
|
||||
|
||||
@@ -31,9 +31,15 @@ export function MediaLibraryModal({
|
||||
mode = SelectionMode.BROWSE,
|
||||
filterType,
|
||||
filterCategory,
|
||||
extraAssets = [],
|
||||
onSelect,
|
||||
onSelectMultiple,
|
||||
onDeleteAsset,
|
||||
selectButtonText,
|
||||
batchSelectButtonText,
|
||||
selectionModeTitle,
|
||||
selectionModeDescription,
|
||||
defaultSelectionModeActive = false,
|
||||
}: MediaLibraryModalProps) {
|
||||
const {
|
||||
assets,
|
||||
@@ -342,7 +348,8 @@ export function MediaLibraryModal({
|
||||
|
||||
// 获取当前选中的资产
|
||||
const selectedAsset =
|
||||
assets.find((a) => a.id === localSelectedAssetId) || null;
|
||||
[...extraAssets, ...assets].find((a) => a.id === localSelectedAssetId) ||
|
||||
null;
|
||||
|
||||
// 显示选择按钮的条件:SELECT模式且有onSelect回调
|
||||
const showSelectButton = mode === 'SELECT' && !!onSelect;
|
||||
@@ -402,19 +409,23 @@ export function MediaLibraryModal({
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div className="media-library-layout">
|
||||
<div className="media-library-layout media-library-layout--picker">
|
||||
{/* 主网格区域 */}
|
||||
<div className="media-library-layout__main">
|
||||
<MediaLibraryGrid
|
||||
extraAssets={extraAssets}
|
||||
filterType={filterType}
|
||||
filterCategory={filterCategory}
|
||||
selectedAssetId={localSelectedAssetId}
|
||||
onSelectAsset={handleSelectAsset}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onFileUpload={handleFileUpload}
|
||||
onUploadClick={handleUploadClick}
|
||||
storageStatus={storageStatus}
|
||||
onFileUpload={handleFileUpload}
|
||||
onUploadClick={handleUploadClick}
|
||||
onDeleteAsset={onDeleteAsset}
|
||||
storageStatus={storageStatus}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
thumbnailFit="cover"
|
||||
defaultSelectionModeActive={defaultSelectionModeActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -438,6 +449,9 @@ export function MediaLibraryModal({
|
||||
showSelectButton={showSelectButton}
|
||||
selecting={isSelecting}
|
||||
selectButtonText={selectButtonText}
|
||||
batchSelectButtonText={batchSelectButtonText}
|
||||
selectionModeTitle={selectionModeTitle}
|
||||
selectionModeDescription={selectionModeDescription}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -470,6 +484,9 @@ export function MediaLibraryModal({
|
||||
showSelectButton={showSelectButton}
|
||||
selecting={isSelecting}
|
||||
selectButtonText={selectButtonText}
|
||||
batchSelectButtonText={batchSelectButtonText}
|
||||
selectionModeTitle={selectionModeTitle}
|
||||
selectionModeDescription={selectionModeDescription}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
@@ -66,7 +66,9 @@ interface VirtualAssetGridProps {
|
||||
onToggleFavorite?: (asset: Asset, event: React.MouseEvent) => void;
|
||||
syncedUrls?: Set<string>; // 已同步到 Gist 的 URL 集合
|
||||
previewOnClick?: boolean;
|
||||
disableHoverPlayback?: boolean;
|
||||
gridAspectRatio?: number;
|
||||
thumbnailFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
interface MarqueeRect {
|
||||
@@ -119,7 +121,9 @@ export function VirtualAssetGrid({
|
||||
onToggleFavorite,
|
||||
syncedUrls,
|
||||
previewOnClick,
|
||||
disableHoverPlayback,
|
||||
gridAspectRatio = 3 / 4,
|
||||
thumbnailFit = 'contain',
|
||||
}: VirtualAssetGridProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const marqueeDragRef = useRef<MarqueeDragState | null>(null);
|
||||
@@ -195,7 +199,13 @@ export function VirtualAssetGrid({
|
||||
width: layout.itemSize,
|
||||
height: Math.round(layout.itemSize / gridAspectRatio),
|
||||
};
|
||||
}, [viewMode, containerWidth, config.padding, gridAspectRatio, layout.itemSize]);
|
||||
}, [
|
||||
viewMode,
|
||||
containerWidth,
|
||||
config.padding,
|
||||
gridAspectRatio,
|
||||
layout.itemSize,
|
||||
]);
|
||||
|
||||
// 计算行数
|
||||
const rowCount = useMemo(() => {
|
||||
@@ -260,6 +270,8 @@ export function VirtualAssetGrid({
|
||||
isFavorite={isFavorite?.(asset.id)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
previewOnClick={previewOnClick}
|
||||
disableHoverPlayback={disableHoverPlayback}
|
||||
thumbnailFit={thumbnailFit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -282,6 +294,8 @@ export function VirtualAssetGrid({
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
previewOnClick,
|
||||
disableHoverPlayback,
|
||||
thumbnailFit,
|
||||
itemSize.height,
|
||||
syncedUrls,
|
||||
]
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--fit-contain &__media {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&--fit-contain &__placeholder {
|
||||
background: linear-gradient(135deg, rgba(255, 91, 0, 0.1), transparent 52%),
|
||||
rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
&__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -45,3 +54,10 @@
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-tg-theme='dark']
|
||||
.video-poster-preview--fit-contain
|
||||
.video-poster-preview__placeholder {
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), transparent 52%),
|
||||
rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('tdesign-icons-react', () => ({
|
||||
PlayCircleIcon: () => <span data-testid="video-play-overlay" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useThumbnailUrl', () => ({
|
||||
useThumbnailUrl: () => undefined,
|
||||
}));
|
||||
|
||||
import { VideoPosterPreview } from './VideoPosterPreview';
|
||||
|
||||
describe('VideoPosterPreview', () => {
|
||||
let nextCanvasReadIsBlack = false;
|
||||
|
||||
beforeEach(() => {
|
||||
nextCanvasReadIsBlack = false;
|
||||
vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue();
|
||||
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
vi.spyOn(
|
||||
window.HTMLVideoElement.prototype,
|
||||
'videoWidth',
|
||||
'get'
|
||||
).mockReturnValue(640);
|
||||
vi.spyOn(
|
||||
window.HTMLVideoElement.prototype,
|
||||
'videoHeight',
|
||||
'get'
|
||||
).mockReturnValue(360);
|
||||
vi.spyOn(
|
||||
window.HTMLCanvasElement.prototype,
|
||||
'getContext'
|
||||
).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => {
|
||||
const value = nextCanvasReadIsBlack ? 0 : 120;
|
||||
nextCanvasReadIsBlack = false;
|
||||
return {
|
||||
data: new Uint8ClampedArray(
|
||||
Array.from({ length: 8 * 8 * 4 }, (_unused, index) =>
|
||||
index % 4 === 3 ? 255 : value
|
||||
)
|
||||
),
|
||||
};
|
||||
}),
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
);
|
||||
vi.spyOn(window.HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
|
||||
'data:image/jpeg;base64,poster-frame'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the playable video as the visible cover and previews on hover when no poster exists', async () => {
|
||||
const { container } = render(
|
||||
<VideoPosterPreview
|
||||
src="/media/generated-result.mp4"
|
||||
poster="/media/missing-poster.jpg"
|
||||
alt="生成视频"
|
||||
fit="contain"
|
||||
playOnHover={true}
|
||||
videoProps={{
|
||||
muted: true,
|
||||
loop: true,
|
||||
playsInline: true,
|
||||
controls: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const video = container.querySelector('video') as HTMLVideoElement;
|
||||
expect(video).toBeInstanceOf(HTMLVideoElement);
|
||||
expect(video.getAttribute('src')).toBe('/media/generated-result.mp4');
|
||||
expect(
|
||||
container.querySelector('.video-poster-preview__placeholder')
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.video-poster-preview--fit-contain')
|
||||
).not.toBeNull();
|
||||
expect(video.controls).toBe(false);
|
||||
expect(video.muted).toBe(true);
|
||||
expect(video.loop).toBe(true);
|
||||
|
||||
fireEvent.loadedData(video);
|
||||
|
||||
await waitFor(() => {
|
||||
const posterImage = screen.getByAltText('生成视频');
|
||||
expect(posterImage).toBeInstanceOf(HTMLImageElement);
|
||||
expect(posterImage.getAttribute('src')).toBe(
|
||||
'data:image/jpeg;base64,poster-frame'
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.mouseEnter(screen.getByAltText('生成视频'));
|
||||
|
||||
const hoverVideo = await waitFor(() => {
|
||||
const node = container.querySelector('video');
|
||||
expect(node).toBeInstanceOf(HTMLVideoElement);
|
||||
return node as HTMLVideoElement;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
hoverVideo.currentTime = 3;
|
||||
fireEvent.mouseLeave(hoverVideo);
|
||||
|
||||
expect(window.HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||
expect(hoverVideo.currentTime).toBe(0);
|
||||
expect(screen.queryByTestId('video-play-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a mostly black poster and extracts a visible video frame', async () => {
|
||||
nextCanvasReadIsBlack = true;
|
||||
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
|
||||
|
||||
const { container } = render(
|
||||
<VideoPosterPreview
|
||||
src="/media/generated-result.mp4"
|
||||
poster="/media/black-cover.jpg"
|
||||
alt="生成视频"
|
||||
fit="contain"
|
||||
playOnHover={true}
|
||||
videoProps={{
|
||||
muted: true,
|
||||
loop: true,
|
||||
playsInline: true,
|
||||
controls: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const posterImage = screen.getByAltText('生成视频');
|
||||
expect(posterImage.getAttribute('src')).toBe(
|
||||
'data:image/jpeg;base64,poster-frame'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PlayCircleIcon } from 'tdesign-icons-react';
|
||||
import { isCanvasFrameMostlyBlack } from '@aitu/utils';
|
||||
import { useThumbnailUrl } from '../../hooks/useThumbnailUrl';
|
||||
import './VideoPosterPreview.scss';
|
||||
|
||||
@@ -8,7 +9,15 @@ const MAX_THUMBNAIL_RETRIES = 4;
|
||||
const FAILED_POSTER_TTL_MS = 10 * 60 * 1000;
|
||||
const RESOLVED_POSTER_TTL_MS = 10 * 60 * 1000;
|
||||
const failedPosterCache = new Map<string, number>();
|
||||
const resolvedPosterCache = new Map<string, { resolvedPoster: string; resolvedAt: number }>();
|
||||
const resolvedPosterCache = new Map<
|
||||
string,
|
||||
{ resolvedPoster: string; resolvedAt: number }
|
||||
>();
|
||||
const extractedVideoPosterCache = new Map<
|
||||
string,
|
||||
{ poster: string; capturedAt: number }
|
||||
>();
|
||||
const videoPosterProbeTimes = [0.6, 1.2, 2, 0.12];
|
||||
|
||||
function cleanupFailedPosterCache(now: number): void {
|
||||
failedPosterCache.forEach((failedAt, url) => {
|
||||
@@ -57,7 +66,9 @@ function getCachedResolvedPoster(url: string | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS ? entry.resolvedPoster : null;
|
||||
return now - entry.resolvedAt <= RESOLVED_POSTER_TTL_MS
|
||||
? entry.resolvedPoster
|
||||
: null;
|
||||
}
|
||||
|
||||
function withPosterRetryParam(url: string, attempt: number): string {
|
||||
@@ -75,15 +86,111 @@ function withPosterRetryParam(url: string, attempt: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isImageMostlyBlack(image: HTMLImageElement): boolean {
|
||||
const sourceWidth = image.naturalWidth;
|
||||
const sourceHeight = image.naturalHeight;
|
||||
if (
|
||||
!sourceWidth ||
|
||||
!sourceHeight ||
|
||||
sourceWidth <= THUMBNAIL_PLACEHOLDER_SIZE ||
|
||||
sourceHeight <= THUMBNAIL_PLACEHOLDER_SIZE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxSide = 160;
|
||||
const scale = Math.min(1, maxSide / Math.max(sourceWidth, sourceHeight));
|
||||
const width = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const height = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return isCanvasFrameMostlyBlack(context, width, height);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheExtractedVideoPoster(src: string, poster: string): void {
|
||||
const now = Date.now();
|
||||
extractedVideoPosterCache.forEach((entry, url) => {
|
||||
if (now - entry.capturedAt > RESOLVED_POSTER_TTL_MS) {
|
||||
extractedVideoPosterCache.delete(url);
|
||||
}
|
||||
});
|
||||
extractedVideoPosterCache.set(src, { poster, capturedAt: now });
|
||||
}
|
||||
|
||||
function getCachedExtractedVideoPoster(src: string): string | null {
|
||||
const entry = extractedVideoPosterCache.get(src);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - entry.capturedAt > RESOLVED_POSTER_TTL_MS) {
|
||||
extractedVideoPosterCache.delete(src);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.poster;
|
||||
}
|
||||
|
||||
function captureVideoFrame(video: HTMLVideoElement): string | null {
|
||||
const sourceWidth = video.videoWidth;
|
||||
const sourceHeight = video.videoHeight;
|
||||
if (!sourceWidth || !sourceHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxSide = 720;
|
||||
const scale = Math.min(1, maxSide / Math.max(sourceWidth, sourceHeight));
|
||||
const width = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const height = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
if (isCanvasFrameMostlyBlack(context, width, height)) {
|
||||
return null;
|
||||
}
|
||||
return canvas.toDataURL('image/jpeg', 0.78);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCaptureWithCors(src: string): boolean {
|
||||
try {
|
||||
return new URL(src, window.location.href).origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoPosterPreviewProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
fit?: 'cover' | 'contain';
|
||||
thumbnailSize?: 'small' | 'large';
|
||||
imageLoading?: 'lazy' | 'eager';
|
||||
activateVideoOnClick?: boolean;
|
||||
playOnActivate?: boolean;
|
||||
playOnHover?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement | HTMLVideoElement>;
|
||||
videoProps?: Omit<
|
||||
React.VideoHTMLAttributes<HTMLVideoElement>,
|
||||
@@ -93,10 +200,11 @@ export interface VideoPosterPreviewProps {
|
||||
|
||||
function renderPreviewWithOverlay(
|
||||
media: React.ReactNode,
|
||||
showPlayOverlay: boolean
|
||||
showPlayOverlay: boolean,
|
||||
fit: 'cover' | 'contain'
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<div className="video-poster-preview">
|
||||
<div className={`video-poster-preview video-poster-preview--fit-${fit}`}>
|
||||
{media}
|
||||
{showPlayOverlay && (
|
||||
<div className="video-poster-preview__play-overlay" aria-hidden="true">
|
||||
@@ -112,39 +220,55 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
poster,
|
||||
alt = '',
|
||||
className,
|
||||
fit = 'cover',
|
||||
thumbnailSize = 'small',
|
||||
imageLoading = 'lazy',
|
||||
activateVideoOnClick = false,
|
||||
playOnActivate = false,
|
||||
playOnHover = false,
|
||||
onClick,
|
||||
videoProps,
|
||||
}) => {
|
||||
const normalizedPoster = useMemo(
|
||||
() => (typeof poster === 'string' && poster.trim() ? poster.trim() : undefined),
|
||||
() =>
|
||||
typeof poster === 'string' && poster.trim() ? poster.trim() : undefined,
|
||||
[poster]
|
||||
);
|
||||
const shouldIgnoreExplicitPoster = useMemo(
|
||||
() => (normalizedPoster ? hasRecentFailedPoster(normalizedPoster) : false),
|
||||
[normalizedPoster]
|
||||
);
|
||||
const generatedPoster = useThumbnailUrl(
|
||||
src,
|
||||
'video',
|
||||
thumbnailSize
|
||||
const generatedPoster = useThumbnailUrl(src, 'video', thumbnailSize);
|
||||
const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(
|
||||
shouldIgnoreExplicitPoster
|
||||
);
|
||||
const [preferGeneratedPoster, setPreferGeneratedPoster] = useState(shouldIgnoreExplicitPoster);
|
||||
const posterCandidate = preferGeneratedPoster ? generatedPoster : (normalizedPoster || generatedPoster);
|
||||
const posterCandidate = preferGeneratedPoster
|
||||
? generatedPoster
|
||||
: normalizedPoster || generatedPoster;
|
||||
const hasExplicitPoster = Boolean(normalizedPoster && !preferGeneratedPoster);
|
||||
const canRetryGeneratedPoster = Boolean(
|
||||
posterCandidate && posterCandidate === generatedPoster && posterCandidate !== src
|
||||
posterCandidate &&
|
||||
posterCandidate === generatedPoster &&
|
||||
posterCandidate !== src
|
||||
);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const activatedByClickRef = useRef(false);
|
||||
const retryTimerRef = useRef<number | null>(null);
|
||||
const [resolvedPoster, setResolvedPoster] = useState<string | null>(() => getCachedResolvedPoster(posterCandidate));
|
||||
const [resolvedPoster, setResolvedPoster] = useState<string | null>(() =>
|
||||
getCachedResolvedPoster(posterCandidate)
|
||||
);
|
||||
const [extractedPoster, setExtractedPoster] = useState<string | null>(() =>
|
||||
getCachedExtractedVideoPoster(src)
|
||||
);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [showVideo, setShowVideo] = useState(false);
|
||||
const shouldRequireExplicitActivation = activateVideoOnClick;
|
||||
const displayPoster =
|
||||
playOnHover && extractedPoster
|
||||
? extractedPoster
|
||||
: resolvedPoster || extractedPoster;
|
||||
const shouldRenderVideoElement = showVideo || (playOnHover && !displayPoster);
|
||||
const videoPoster = displayPoster || undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setPreferGeneratedPoster(shouldIgnoreExplicitPoster);
|
||||
@@ -152,6 +276,7 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedPoster(getCachedResolvedPoster(posterCandidate));
|
||||
setExtractedPoster(getCachedExtractedVideoPoster(src));
|
||||
setRetryCount(0);
|
||||
setShowVideo(false);
|
||||
activatedByClickRef.current = false;
|
||||
@@ -159,7 +284,14 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
}, [src, posterCandidate, activateVideoOnClick, generatedPoster, normalizedPoster, thumbnailSize]);
|
||||
}, [
|
||||
src,
|
||||
posterCandidate,
|
||||
activateVideoOnClick,
|
||||
generatedPoster,
|
||||
normalizedPoster,
|
||||
thumbnailSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showVideo || (resolvedPoster && resolvedPoster === posterCandidate)) {
|
||||
@@ -197,16 +329,25 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hasExplicitPoster ||
|
||||
probeImage.naturalWidth > THUMBNAIL_PLACEHOLDER_SIZE ||
|
||||
probeImage.naturalHeight > THUMBNAIL_PLACEHOLDER_SIZE
|
||||
) {
|
||||
const isUsablePoster =
|
||||
(probeImage.naturalWidth > THUMBNAIL_PLACEHOLDER_SIZE ||
|
||||
probeImage.naturalHeight > THUMBNAIL_PLACEHOLDER_SIZE) &&
|
||||
!isImageMostlyBlack(probeImage);
|
||||
|
||||
if (isUsablePoster) {
|
||||
cacheResolvedPoster(posterCandidate, posterCandidate);
|
||||
setResolvedPoster(posterCandidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExplicitPoster && normalizedPoster) {
|
||||
markPosterAsFailed(normalizedPoster);
|
||||
setPreferGeneratedPoster(true);
|
||||
setResolvedPoster(null);
|
||||
setRetryCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleRetry();
|
||||
};
|
||||
|
||||
@@ -250,15 +391,131 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVideo || !playOnActivate || !activatedByClickRef.current) {
|
||||
if (!playOnHover || extractedPoster) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedPoster = getCachedExtractedVideoPoster(src);
|
||||
if (cachedPoster) {
|
||||
setExtractedPoster(cachedPoster);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const probeVideo = document.createElement('video');
|
||||
probeVideo.muted = true;
|
||||
probeVideo.playsInline = true;
|
||||
probeVideo.preload = 'auto';
|
||||
if (shouldCaptureWithCors(src)) {
|
||||
probeVideo.crossOrigin = 'anonymous';
|
||||
}
|
||||
|
||||
let seekIndex = 0;
|
||||
|
||||
const nextProbeTime = (): number | null => {
|
||||
const duration = Number.isFinite(probeVideo.duration)
|
||||
? probeVideo.duration
|
||||
: 0;
|
||||
while (seekIndex < videoPosterProbeTimes.length) {
|
||||
const nextTime = videoPosterProbeTimes[seekIndex];
|
||||
seekIndex += 1;
|
||||
if (duration > 0 && nextTime >= duration) {
|
||||
continue;
|
||||
}
|
||||
return duration > 0 ? Math.min(nextTime, duration - 0.05) : nextTime;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const seekNextFrame = () => {
|
||||
const targetTime = nextProbeTime();
|
||||
if (targetTime === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Math.abs(probeVideo.currentTime - targetTime) < 0.03) {
|
||||
capture();
|
||||
} else {
|
||||
probeVideo.currentTime = targetTime;
|
||||
}
|
||||
} catch {
|
||||
seekNextFrame();
|
||||
}
|
||||
};
|
||||
|
||||
const capture = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posterDataUrl = captureVideoFrame(probeVideo);
|
||||
if (!posterDataUrl) {
|
||||
seekNextFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
cacheExtractedVideoPoster(src, posterDataUrl);
|
||||
setExtractedPoster(posterDataUrl);
|
||||
};
|
||||
|
||||
const seekFirstFrame = () => {
|
||||
capture();
|
||||
seekNextFrame();
|
||||
};
|
||||
|
||||
probeVideo.addEventListener('loadedmetadata', seekFirstFrame);
|
||||
probeVideo.addEventListener('loadeddata', capture);
|
||||
probeVideo.addEventListener('seeked', capture);
|
||||
probeVideo.src = src;
|
||||
probeVideo.load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
probeVideo.removeEventListener('loadedmetadata', seekFirstFrame);
|
||||
probeVideo.removeEventListener('loadeddata', capture);
|
||||
probeVideo.removeEventListener('seeked', capture);
|
||||
try {
|
||||
probeVideo.removeAttribute('src');
|
||||
probeVideo.load();
|
||||
} catch {
|
||||
// Ignore cleanup errors from partially loaded media.
|
||||
}
|
||||
};
|
||||
}, [src, playOnHover, extractedPoster]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!shouldRenderVideoElement ||
|
||||
!playOnActivate ||
|
||||
!activatedByClickRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activatedByClickRef.current = false;
|
||||
videoRef.current?.play().catch(() => {
|
||||
// 浏览器策略可能阻止自动播放,保留 controls 让用户继续点播
|
||||
// Browsers can block programmatic playback; the card still opens preview on click.
|
||||
});
|
||||
}, [showVideo, playOnActivate]);
|
||||
}, [shouldRenderVideoElement, playOnActivate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playOnHover || !showVideo || !shouldRenderVideoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoRef.current;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {
|
||||
// Hover playback is a preview nicety; browsers may still block it.
|
||||
});
|
||||
}
|
||||
}, [playOnHover, showVideo, shouldRenderVideoElement]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -277,7 +534,9 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePosterClick: React.MouseEventHandler<HTMLImageElement> = (event) => {
|
||||
const handlePosterClick: React.MouseEventHandler<HTMLImageElement> = (
|
||||
event
|
||||
) => {
|
||||
if (activateVideoOnClick) {
|
||||
activatedByClickRef.current = true;
|
||||
setShowVideo(true);
|
||||
@@ -285,31 +544,88 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
const handleVideoClick: React.MouseEventHandler<HTMLVideoElement> = (event) => {
|
||||
const handleVideoClick: React.MouseEventHandler<HTMLVideoElement> = (
|
||||
event
|
||||
) => {
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
const handleVideoLoadedData: React.ReactEventHandler<HTMLVideoElement> = (
|
||||
event
|
||||
) => {
|
||||
videoProps?.onLoadedData?.(event);
|
||||
if (!playOnHover || extractedPoster) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posterDataUrl = captureVideoFrame(event.currentTarget);
|
||||
if (!posterDataUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
cacheExtractedVideoPoster(src, posterDataUrl);
|
||||
setExtractedPoster(posterDataUrl);
|
||||
};
|
||||
|
||||
const handlePreviewMouseEnter: React.MouseEventHandler<
|
||||
HTMLImageElement | HTMLDivElement | HTMLVideoElement
|
||||
> = () => {
|
||||
if (!playOnHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowVideo(true);
|
||||
};
|
||||
|
||||
const handlePreviewMouseLeave: React.MouseEventHandler<
|
||||
HTMLImageElement | HTMLDivElement | HTMLVideoElement
|
||||
> = () => {
|
||||
if (!playOnHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// Ignore browsers that reject seeking before metadata is ready.
|
||||
}
|
||||
}
|
||||
|
||||
setShowVideo(false);
|
||||
};
|
||||
|
||||
const showPlayOverlay = activateVideoOnClick && !showVideo;
|
||||
|
||||
if (!showVideo && resolvedPoster) {
|
||||
if (!shouldRenderVideoElement && displayPoster) {
|
||||
return renderPreviewWithOverlay(
|
||||
<img
|
||||
src={resolvedPoster}
|
||||
src={displayPoster}
|
||||
alt={alt}
|
||||
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
||||
className={`video-poster-preview__media${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
loading={imageLoading}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
onClick={handlePosterClick}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
showPlayOverlay
|
||||
showPlayOverlay,
|
||||
fit
|
||||
);
|
||||
}
|
||||
|
||||
if (!showVideo && !resolvedPoster) {
|
||||
if (!shouldRenderVideoElement && !resolvedPoster) {
|
||||
return renderPreviewWithOverlay(
|
||||
<div
|
||||
className={`video-poster-preview__placeholder${className ? ` ${className}` : ''}`}
|
||||
className={`video-poster-preview__placeholder${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
onClick={
|
||||
activateVideoOnClick
|
||||
@@ -319,8 +635,11 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
showPlayOverlay
|
||||
showPlayOverlay,
|
||||
fit
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,12 +647,19 @@ export const VideoPosterPreview: React.FC<VideoPosterPreviewProps> = ({
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={resolvedPoster || normalizedPoster}
|
||||
className={`video-poster-preview__media${className ? ` ${className}` : ''}`}
|
||||
poster={videoPoster}
|
||||
className={`video-poster-preview__media${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
playsInline
|
||||
{...videoProps}
|
||||
muted={playOnHover || videoProps?.muted}
|
||||
onClick={handleVideoClick}
|
||||
onLoadedData={handleVideoLoadedData}
|
||||
onMouseEnter={handlePreviewMouseEnter}
|
||||
onMouseLeave={handlePreviewMouseLeave}
|
||||
/>,
|
||||
false
|
||||
false,
|
||||
fit
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
ImagePlus,
|
||||
MoreHorizontal,
|
||||
Music2,
|
||||
PlayCircle,
|
||||
RefreshCcw,
|
||||
Scissors,
|
||||
Send,
|
||||
Trash2,
|
||||
Video as VideoIcon,
|
||||
@@ -62,6 +62,9 @@ import './dialog-task-loading-final.scss';
|
||||
|
||||
const TRUEGROWTH_PUBLISH_HANDOFF_EVENT = 'truegrowth:publish-asset';
|
||||
const TRUEGROWTH_PUBLISH_HANDOFF_STORAGE_KEY = 'truegrowth-publish-handoff-v1';
|
||||
const TRUEGROWTH_TIMELINE_HANDOFF_EVENT = 'truegrowth:timeline-asset';
|
||||
const TRUEGROWTH_TIMELINE_HANDOFF_STORAGE_KEY =
|
||||
'truegrowth-timeline-handoff-v1';
|
||||
const FALLBACK_TASK_FAILURE_REASON = '生成失败,请检查模型服务或参数配置。';
|
||||
const TECHNICAL_FAILURE_PATTERNS = [
|
||||
/\b(seedance|volcengine|comfyui|api|endpoint|fetch|http|https|json|request|response|submit|xhr|network|timeout|exception|stack|trace)\b/i,
|
||||
@@ -456,70 +459,72 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const items: InsertionItem[] = insertableTasks.flatMap<InsertionItem>((task) => {
|
||||
if (task.type === TaskType.IMAGE) {
|
||||
const urls = task.result!.urls?.length
|
||||
? task.result!.urls
|
||||
: [task.result!.url];
|
||||
return urls.map((url) => ({
|
||||
type: 'image' as const,
|
||||
content: normalizeImageDataUrl(url),
|
||||
groupId: `dialog-batch-image-${task.id}`,
|
||||
}));
|
||||
}
|
||||
if (task.type === TaskType.VIDEO && task.result?.url) {
|
||||
return [
|
||||
{
|
||||
type: 'video' as const,
|
||||
content: task.result.url,
|
||||
groupId: `dialog-batch-video-${task.id}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (task.type === TaskType.AUDIO) {
|
||||
const urls = resolveAudioResultUrls(task);
|
||||
return urls.map((url, index) => {
|
||||
const clip = task.result?.clips?.[index];
|
||||
return {
|
||||
type: 'audio' as const,
|
||||
content: url,
|
||||
groupId: `dialog-batch-audio-${task.id}`,
|
||||
metadata: {
|
||||
title:
|
||||
clip?.title ||
|
||||
task.result?.title ||
|
||||
task.params.title ||
|
||||
task.params.prompt,
|
||||
duration:
|
||||
typeof clip?.duration === 'number'
|
||||
? clip.duration || undefined
|
||||
: task.result?.duration,
|
||||
previewImageUrl:
|
||||
clip?.imageLargeUrl ||
|
||||
clip?.imageUrl ||
|
||||
task.result?.previewImageUrl,
|
||||
tags:
|
||||
typeof task.params.tags === 'string'
|
||||
? task.params.tags
|
||||
: undefined,
|
||||
mv:
|
||||
typeof task.params.mv === 'string'
|
||||
? task.params.mv
|
||||
: undefined,
|
||||
prompt: task.params.prompt,
|
||||
providerTaskId: task.result?.providerTaskId || task.remoteId,
|
||||
clipId:
|
||||
clip?.clipId ||
|
||||
clip?.id ||
|
||||
task.result?.clipIds?.[index] ||
|
||||
task.result?.primaryClipId,
|
||||
clipIds: task.result?.clipIds,
|
||||
const items: InsertionItem[] = insertableTasks.flatMap<InsertionItem>(
|
||||
(task) => {
|
||||
if (task.type === TaskType.IMAGE) {
|
||||
const urls = task.result!.urls?.length
|
||||
? task.result!.urls
|
||||
: [task.result!.url];
|
||||
return urls.map((url) => ({
|
||||
type: 'image' as const,
|
||||
content: normalizeImageDataUrl(url),
|
||||
groupId: `dialog-batch-image-${task.id}`,
|
||||
}));
|
||||
}
|
||||
if (task.type === TaskType.VIDEO && task.result?.url) {
|
||||
return [
|
||||
{
|
||||
type: 'video' as const,
|
||||
content: task.result.url,
|
||||
groupId: `dialog-batch-video-${task.id}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
];
|
||||
}
|
||||
if (task.type === TaskType.AUDIO) {
|
||||
const urls = resolveAudioResultUrls(task);
|
||||
return urls.map((url, index) => {
|
||||
const clip = task.result?.clips?.[index];
|
||||
return {
|
||||
type: 'audio' as const,
|
||||
content: url,
|
||||
groupId: `dialog-batch-audio-${task.id}`,
|
||||
metadata: {
|
||||
title:
|
||||
clip?.title ||
|
||||
task.result?.title ||
|
||||
task.params.title ||
|
||||
task.params.prompt,
|
||||
duration:
|
||||
typeof clip?.duration === 'number'
|
||||
? clip.duration || undefined
|
||||
: task.result?.duration,
|
||||
previewImageUrl:
|
||||
clip?.imageLargeUrl ||
|
||||
clip?.imageUrl ||
|
||||
task.result?.previewImageUrl,
|
||||
tags:
|
||||
typeof task.params.tags === 'string'
|
||||
? task.params.tags
|
||||
: undefined,
|
||||
mv:
|
||||
typeof task.params.mv === 'string'
|
||||
? task.params.mv
|
||||
: undefined,
|
||||
prompt: task.params.prompt,
|
||||
providerTaskId: task.result?.providerTaskId || task.remoteId,
|
||||
clipId:
|
||||
clip?.clipId ||
|
||||
clip?.id ||
|
||||
task.result?.clipIds?.[index] ||
|
||||
task.result?.primaryClipId,
|
||||
clipIds: task.result?.clipIds,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
);
|
||||
await executeCanvasInsertion({ items });
|
||||
MessagePlugin.success(`已插入 ${items.length} 个作品到画布`);
|
||||
} catch (error) {
|
||||
@@ -615,6 +620,79 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
MessagePlugin.success(`已带入 ${assets.length} 个作品到发布中心`);
|
||||
};
|
||||
|
||||
const handleClip = async (taskId: string) => {
|
||||
const task = await resolveTask(taskId);
|
||||
if (
|
||||
!task ||
|
||||
task.type !== TaskType.VIDEO ||
|
||||
task.status !== TaskStatus.COMPLETED ||
|
||||
!task.result
|
||||
) {
|
||||
MessagePlugin.warning('当前视频还不能剪辑');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = task.result.urls?.[0] || task.result.url;
|
||||
if (!url) {
|
||||
MessagePlugin.warning('当前视频没有可剪辑的视频源');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = getFeedTitle(task);
|
||||
const detail = {
|
||||
asset: {
|
||||
id: `task-${task.id}`,
|
||||
assetId: `task-${task.id}`,
|
||||
type: 'video',
|
||||
name: title,
|
||||
source: 'ai-video',
|
||||
provider: task.params.model || 'TrueGrowth',
|
||||
url,
|
||||
thumbnailUrl:
|
||||
task.result.thumbnailUrls?.[0] ||
|
||||
task.result.previewImageUrl ||
|
||||
undefined,
|
||||
durationMs:
|
||||
typeof task.result.duration === 'number'
|
||||
? task.result.duration * 1000
|
||||
: undefined,
|
||||
width: task.result.width,
|
||||
height: task.result.height,
|
||||
createdAt: task.completedAt || task.createdAt || Date.now(),
|
||||
publishState: 'unpublished',
|
||||
sourceRefs: [
|
||||
{
|
||||
type: 'task',
|
||||
id: task.id,
|
||||
label: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
route: '/timeline',
|
||||
intent: 'clip',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
TRUEGROWTH_TIMELINE_HANDOFF_STORAGE_KEY,
|
||||
JSON.stringify(detail)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[TrueGrowth] Failed to store timeline handoff:', error);
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TRUEGROWTH_TIMELINE_HANDOFF_EVENT, { detail })
|
||||
);
|
||||
if (window.location.pathname !== '/timeline') {
|
||||
window.history.pushState(null, '', '/timeline');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}
|
||||
MessagePlugin.success('已带入剪辑工作台,请选择剪辑能力');
|
||||
};
|
||||
|
||||
const confirmOverwriteDraftIfNeeded = useCallback(async () => {
|
||||
if (!hasAIImageDraftContent()) {
|
||||
return true;
|
||||
@@ -956,6 +1034,7 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
onDelete={handleDelete}
|
||||
onDeleteBatch={handleDeleteBatch}
|
||||
onPublishBatch={handlePublishBatch}
|
||||
onClip={handleClip}
|
||||
onPreviewOpen={handlePreviewOpen}
|
||||
/>
|
||||
</React.Fragment>
|
||||
@@ -975,11 +1054,7 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<div className="dialog-task-list__empty">
|
||||
{isLoading ? (
|
||||
<p>加载中...</p>
|
||||
) : (
|
||||
<p>{feedEmptyText}</p>
|
||||
)}
|
||||
{isLoading ? <p>加载中...</p> : <p>{feedEmptyText}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1321,9 +1396,11 @@ function getTaskMediaItems(task: Task): CreationBatchMedia[] {
|
||||
taskId: task.id,
|
||||
url: task.type === TaskType.IMAGE ? normalizeImageDataUrl(url) : url,
|
||||
thumbnailUrl:
|
||||
task.result?.thumbnailUrls?.[index] ||
|
||||
task.result?.previewImageUrl ||
|
||||
(task.type === TaskType.IMAGE ? normalizeImageDataUrl(url) : url),
|
||||
task.type === TaskType.VIDEO
|
||||
? task.result?.thumbnailUrls?.[index] || task.result?.previewImageUrl
|
||||
: task.result?.thumbnailUrls?.[index] ||
|
||||
task.result?.previewImageUrl ||
|
||||
normalizeImageDataUrl(url),
|
||||
duration:
|
||||
task.type === TaskType.AUDIO && typeof task.result?.duration === 'number'
|
||||
? task.result.duration
|
||||
@@ -1723,6 +1800,7 @@ function CreationBatchCard({
|
||||
onDelete,
|
||||
onDeleteBatch,
|
||||
onPublishBatch,
|
||||
onClip,
|
||||
onPreviewOpen,
|
||||
}: {
|
||||
batch: CreationBatch;
|
||||
@@ -1734,6 +1812,7 @@ function CreationBatchCard({
|
||||
onDelete?: (taskId: string) => void;
|
||||
onDeleteBatch?: (taskIds: string[]) => void;
|
||||
onPublishBatch?: (taskIds: string[]) => void;
|
||||
onClip?: (taskId: string) => void;
|
||||
onPreviewOpen?: (previewKey: string) => void;
|
||||
}) {
|
||||
const mediaItems = getBatchMediaItems(batch);
|
||||
@@ -1755,6 +1834,12 @@ function CreationBatchCard({
|
||||
(task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) &&
|
||||
hasTaskResultMedia(task)
|
||||
);
|
||||
const firstCompletedVideoTask = batch.tasks.find(
|
||||
(task) =>
|
||||
task.type === TaskType.VIDEO &&
|
||||
task.status === TaskStatus.COMPLETED &&
|
||||
hasTaskResultMedia(task)
|
||||
);
|
||||
const hasFailedTask = batch.tasks.some(
|
||||
(task) => task.status === TaskStatus.FAILED
|
||||
);
|
||||
@@ -1830,16 +1915,22 @@ function CreationBatchCard({
|
||||
const placeholderStyle = !isCompleted
|
||||
? getTaskPlaceholderStyle(media.task)
|
||||
: undefined;
|
||||
const mediaStyle =
|
||||
isCompleted && isVideo
|
||||
? getTaskPlaceholderStyle(media.task)
|
||||
: placeholderStyle;
|
||||
const mediaTaskIds = [media.taskId];
|
||||
return (
|
||||
<div className="tg-creation-batch__item" key={media.id}>
|
||||
<div
|
||||
className={`tg-creation-batch__media${
|
||||
isVideo ? ' tg-creation-batch__media--video' : ''
|
||||
}${
|
||||
mediaFailureReason ? ' tg-creation-batch__media--failed' : ''
|
||||
}${
|
||||
isProcessing ? ' tg-creation-batch__media--processing' : ''
|
||||
}`}
|
||||
style={placeholderStyle}
|
||||
style={mediaStyle}
|
||||
>
|
||||
<button
|
||||
className="tg-creation-batch__media-preview"
|
||||
@@ -1863,11 +1954,15 @@ function CreationBatchCard({
|
||||
src={media.url!}
|
||||
poster={media.thumbnailUrl}
|
||||
alt={media.title}
|
||||
fit="contain"
|
||||
thumbnailSize="small"
|
||||
playOnHover={true}
|
||||
videoProps={{
|
||||
muted: true,
|
||||
loop: true,
|
||||
playsInline: true,
|
||||
preload: 'metadata',
|
||||
controls: false,
|
||||
}}
|
||||
/>
|
||||
) : isCompleted && isAudio ? (
|
||||
@@ -1943,9 +2038,9 @@ function CreationBatchCard({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(isVideo || isAudio) && isCompleted ? (
|
||||
{isAudio && isCompleted ? (
|
||||
<span className="tg-creation-batch__play" aria-hidden="true">
|
||||
{isAudio ? <Music2 size={22} /> : <PlayCircle size={28} />}
|
||||
<Music2 size={22} />
|
||||
</span>
|
||||
) : null}
|
||||
{isCompleted ? (
|
||||
@@ -1963,6 +2058,21 @@ function CreationBatchCard({
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
</HoverTip>
|
||||
{isVideo ? (
|
||||
<HoverTip content="去剪辑">
|
||||
<button
|
||||
className="tg-creation-batch__icon"
|
||||
type="button"
|
||||
aria-label="去剪辑"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClip?.(media.taskId);
|
||||
}}
|
||||
>
|
||||
<Scissors size={15} />
|
||||
</button>
|
||||
</HoverTip>
|
||||
) : null}
|
||||
<HoverTip content="插入画布">
|
||||
<button
|
||||
className="tg-creation-batch__icon"
|
||||
@@ -2049,6 +2159,15 @@ function CreationBatchCard({
|
||||
<span>插入画布</span>
|
||||
</button>
|
||||
) : null}
|
||||
{firstCompletedVideoTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClip?.(firstCompletedVideoTask.id)}
|
||||
>
|
||||
<Scissors size={15} />
|
||||
<span>去剪辑</span>
|
||||
</button>
|
||||
) : null}
|
||||
{hasPublishableMedia ? (
|
||||
<details className="tg-creation-batch__more">
|
||||
<summary>
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
import { LocateFixed } from 'lucide-react';
|
||||
import { normalizeImageDataUrl } from '@aitu/utils';
|
||||
import { Task, TaskStatus, TaskType } from '../../types/task.types';
|
||||
import { formatDateTime, formatTaskDuration } from '../../utils/task-utils';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatTaskDuration,
|
||||
getTaskTimelineItems,
|
||||
} from '../../utils/task-utils';
|
||||
import { useUnifiedCache } from '../../hooks/useUnifiedCache';
|
||||
import {
|
||||
supportsCharacterExtraction,
|
||||
@@ -250,6 +254,7 @@ export const TaskItem: React.FC<TaskItemProps> = React.memo(
|
||||
const isFailed = task.status === TaskStatus.FAILED;
|
||||
const isCancelled = task.status === TaskStatus.CANCELLED;
|
||||
const isRetryable = isFailed || isCancelled;
|
||||
const taskTimelineItems = getTaskTimelineItems(task);
|
||||
const hasResult =
|
||||
Boolean(task.result?.url || task.result?.urls?.length) ||
|
||||
Boolean(task.result?.chatResponse) ||
|
||||
@@ -815,12 +820,21 @@ export const TaskItem: React.FC<TaskItemProps> = React.memo(
|
||||
</div>
|
||||
|
||||
<div className="task-item__details">
|
||||
<span className="task-item__time">
|
||||
{formatDateTime(task.createdAt)}
|
||||
</span>
|
||||
{taskTimelineItems.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className="task-item__time"
|
||||
title={`${item.label}时间:${item.value}`}
|
||||
>
|
||||
<span className="task-item__detail-label">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.value}
|
||||
</span>
|
||||
))}
|
||||
{task.startedAt && (
|
||||
<span className="task-item__duration">
|
||||
·{' '}
|
||||
<span className="task-item__detail-label">耗时</span>
|
||||
{formatTaskDuration(
|
||||
(task.completedAt || Date.now()) - task.startedAt
|
||||
)}
|
||||
@@ -1083,6 +1097,10 @@ export const TaskItem: React.FC<TaskItemProps> = React.memo(
|
||||
prev.task.id === next.task.id &&
|
||||
prev.task.status === next.task.status &&
|
||||
prev.task.progress === next.task.progress &&
|
||||
prev.task.createdAt === next.task.createdAt &&
|
||||
prev.task.updatedAt === next.task.updatedAt &&
|
||||
prev.task.completedAt === next.task.completedAt &&
|
||||
prev.task.startedAt === next.task.startedAt &&
|
||||
prev.task.error?.message === next.task.error?.message &&
|
||||
prev.task.result === next.task.result &&
|
||||
prev.isSelected === next.isSelected &&
|
||||
|
||||
@@ -312,8 +312,7 @@
|
||||
box-sizing: border-box;
|
||||
padding: 14px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 91, 0, 0.12), transparent 54%),
|
||||
background: linear-gradient(135deg, rgba(255, 91, 0, 0.12), transparent 54%),
|
||||
#ffffff;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
@@ -373,8 +372,7 @@
|
||||
}
|
||||
|
||||
:root[data-tg-theme='dark'] .tg-creation-batch__audio-card {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 138, 61, 0.16), transparent 54%),
|
||||
background: linear-gradient(135deg, rgba(255, 138, 61, 0.16), transparent 54%),
|
||||
#1b1b1d;
|
||||
color: #f5f6f8;
|
||||
}
|
||||
@@ -392,6 +390,30 @@
|
||||
color: rgba(245, 246, 248, 0.52);
|
||||
}
|
||||
|
||||
.tg-creation-batch__media--video,
|
||||
.tg-creation-batch__media--video > .tg-creation-batch__media-preview {
|
||||
width: var(--tg-creation-placeholder-width, 360px);
|
||||
max-width: min(42vw, var(--tg-creation-placeholder-width, 360px));
|
||||
aspect-ratio: var(--tg-creation-placeholder-ratio, 16 / 9);
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.tg-creation-batch__media--video .video-poster-preview,
|
||||
.tg-creation-batch__media--video .video-poster-preview__media,
|
||||
.tg-creation-batch__media--video .video-poster-preview__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:root[data-tg-theme='dark'] .tg-creation-batch__media--video,
|
||||
:root[data-tg-theme='dark']
|
||||
.tg-creation-batch__media--video
|
||||
> .tg-creation-batch__media-preview {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* TrueGrowth media generator loading: title, ring and status stay stacked. */
|
||||
body
|
||||
.tg-workbench
|
||||
|
||||
@@ -683,10 +683,24 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 4px 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__time,
|
||||
&__duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__detail-label {
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__progress-container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) auto;
|
||||
|
||||
@@ -633,6 +633,15 @@ const AIVideoGeneration = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedSelectedModel = findMatchingSelectableModel(
|
||||
visibleVideoModels,
|
||||
selectedModel,
|
||||
selectedModelRef
|
||||
);
|
||||
if (!matchedSelectedModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelectionKey = getSelectionKey(selectedModel, selectedModelRef);
|
||||
if (lastSyncedSelectedSelectionKeyRef.current === nextSelectionKey) {
|
||||
return;
|
||||
@@ -651,7 +660,7 @@ const AIVideoGeneration = ({
|
||||
const nextModelRef = resolveVideoModelRef(
|
||||
visibleVideoModels,
|
||||
selectedModel,
|
||||
selectedModelRef
|
||||
getModelRefFromConfig(matchedSelectedModel) || selectedModelRef
|
||||
);
|
||||
setCurrentModelRef((prev) => {
|
||||
if (areModelRefsEqual(prev, nextModelRef)) {
|
||||
|
||||
@@ -11,8 +11,14 @@ export const VersionUpdatePrompt: React.FC = () => {
|
||||
const [showChangelog, setShowChangelog] = useState(false);
|
||||
const { activeTasks } = useTaskQueue();
|
||||
// const { t } = useI18n(); // Assuming i18n is available, if not fallback to strings
|
||||
const isTrueGrowthDesktop =
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean((window as Window & { trueGrowthRuntime?: unknown }).trueGrowthRuntime);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTrueGrowthDesktop) {
|
||||
return undefined;
|
||||
}
|
||||
// Listen for custom event from main.tsx
|
||||
const handleUpdateAvailable = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
@@ -70,7 +76,7 @@ export const VersionUpdatePrompt: React.FC = () => {
|
||||
return () => {
|
||||
window.removeEventListener('sw-update-available', handleUpdateAvailable);
|
||||
};
|
||||
}, []);
|
||||
}, [isTrueGrowthDesktop]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
// Keep the prompt visible until the new SW actually takes over.
|
||||
@@ -81,7 +87,7 @@ export const VersionUpdatePrompt: React.FC = () => {
|
||||
};
|
||||
|
||||
// Only show if update is available AND no active tasks
|
||||
if (!updateAvailable || activeTasks.length > 0) {
|
||||
if (isTrueGrowthDesktop || !updateAvailable || activeTasks.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,4 +88,86 @@ describe('seedance video adapter', () => {
|
||||
expect(body.get('size')).toBe('16:9');
|
||||
expect(requests[1]?.url).toBe('/api/truemodel/v1/videos/task-1');
|
||||
});
|
||||
|
||||
it('does not treat active 100 percent progress as completed before a result url exists', async () => {
|
||||
const progressEvents: Array<{ progress: number; status?: string }> = [];
|
||||
const fetcher = vi.fn(
|
||||
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if ((init?.method || 'GET') === 'POST') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'task-active-100',
|
||||
status: 'queued',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const queryIndex = fetcher.mock.calls.filter(
|
||||
([, callInit]) => (callInit?.method || 'GET') !== 'POST'
|
||||
).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
queryIndex === 1
|
||||
? {
|
||||
id: 'task-active-100',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
}
|
||||
: {
|
||||
id: 'task-active-100',
|
||||
status: 'succeeded',
|
||||
progress: 100,
|
||||
data: {
|
||||
url: 'https://cdn.example.com/seedance-data-url.mp4',
|
||||
},
|
||||
seconds: '5',
|
||||
}
|
||||
),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const context: AdapterContext = {
|
||||
baseUrl: 'https://truemodel.benchu.cloud/v1',
|
||||
apiKey: 'sk-test',
|
||||
authType: 'bearer',
|
||||
operation: 'video',
|
||||
fetcher,
|
||||
};
|
||||
|
||||
const resultPromise = seedanceVideoAdapter.generateVideo(context, {
|
||||
model: 'doubao-seedance-1-5-pro-251215',
|
||||
prompt: 'tiny city at night',
|
||||
size: '480p@1:1',
|
||||
duration: 5,
|
||||
params: {
|
||||
onProgress: (progress: number, status?: string) => {
|
||||
progressEvents.push({ progress, status });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.url).toBe('https://cdn.example.com/seedance-data-url.mp4');
|
||||
expect(progressEvents).toContainEqual({
|
||||
progress: 99,
|
||||
status: 'processing',
|
||||
});
|
||||
expect(progressEvents).toContainEqual({
|
||||
progress: 100,
|
||||
status: 'succeeded',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -844,10 +844,7 @@ export class FallbackMediaExecutor implements IMediaExecutor {
|
||||
}
|
||||
|
||||
if (!finalTask || finalTask.status !== 'completed') {
|
||||
if (
|
||||
finalTask?.status === 'queued' ||
|
||||
finalTask?.status === 'running'
|
||||
) {
|
||||
if (finalTask?.status === 'queued' || finalTask?.status === 'running') {
|
||||
const message =
|
||||
'ComfyUI 本地生图仍在执行。FLUX 首次加载或批量生成会很慢,任务会继续在后台刷新,请稍后在任务中心查看。';
|
||||
await taskStorageWriter.updateStatus(taskId, 'processing');
|
||||
@@ -1600,12 +1597,15 @@ export class FallbackMediaExecutor implements IMediaExecutor {
|
||||
};
|
||||
|
||||
// 始终先写入 IndexedDB,确保持久化
|
||||
await taskStorageWriter.completeTask(task.id, completionResult);
|
||||
const persistedResult = await taskStorageWriter.completeTask(
|
||||
task.id,
|
||||
completionResult
|
||||
);
|
||||
|
||||
// 再通知内存状态同步
|
||||
if (onTaskUpdate) {
|
||||
onTaskUpdate(task.id, TaskStatus.COMPLETED, {
|
||||
result: completionResult,
|
||||
result: persistedResult,
|
||||
progress: 100,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { normalizeImageDataUrl } from '@aitu/utils';
|
||||
import { APP_DB_NAME, APP_DB_STORES } from '../app-database';
|
||||
import type { TaskInvocationRouteSnapshot } from '../../types/task.types';
|
||||
import { withGeneratedVideoCover } from './video-cover-utils';
|
||||
|
||||
// 使用主线程专用数据库
|
||||
const DB_NAME = APP_DB_NAME;
|
||||
@@ -24,7 +25,12 @@ type SWTaskType =
|
||||
| 'character'
|
||||
| 'inspiration_board'
|
||||
| 'chat';
|
||||
type SWTaskStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
type SWTaskStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* SW 端的任务结构(与 SWTask 保持一致)
|
||||
@@ -245,23 +251,32 @@ class TaskStorageWriter {
|
||||
/**
|
||||
* 完成任务
|
||||
*/
|
||||
async completeTask(taskId: string, result: SWTask['result']): Promise<void> {
|
||||
async completeTask(
|
||||
taskId: string,
|
||||
result: SWTask['result']
|
||||
): Promise<SWTask['result']> {
|
||||
const task = await this.getTask(taskId);
|
||||
if (task) {
|
||||
const resultWithGeneratedCover =
|
||||
task.type === 'video' && result
|
||||
? await withGeneratedVideoCover(taskId, result)
|
||||
: result;
|
||||
const normalizedResult =
|
||||
task.type === 'image' && result
|
||||
task.type === 'image' && resultWithGeneratedCover
|
||||
? {
|
||||
...result,
|
||||
url: normalizeImageDataUrl(result.url),
|
||||
urls: result.urls?.map((url) => normalizeImageDataUrl(url)),
|
||||
thumbnailUrl: result.thumbnailUrl
|
||||
? normalizeImageDataUrl(result.thumbnailUrl)
|
||||
: result.thumbnailUrl,
|
||||
thumbnailUrls: result.thumbnailUrls?.map((url) =>
|
||||
...resultWithGeneratedCover,
|
||||
url: normalizeImageDataUrl(resultWithGeneratedCover.url),
|
||||
urls: resultWithGeneratedCover.urls?.map((url) =>
|
||||
normalizeImageDataUrl(url)
|
||||
),
|
||||
thumbnailUrl: resultWithGeneratedCover.thumbnailUrl
|
||||
? normalizeImageDataUrl(resultWithGeneratedCover.thumbnailUrl)
|
||||
: resultWithGeneratedCover.thumbnailUrl,
|
||||
thumbnailUrls: resultWithGeneratedCover.thumbnailUrls?.map(
|
||||
(url) => normalizeImageDataUrl(url)
|
||||
),
|
||||
}
|
||||
: result;
|
||||
: resultWithGeneratedCover;
|
||||
|
||||
task.status = 'completed';
|
||||
task.result = normalizedResult;
|
||||
@@ -269,7 +284,9 @@ class TaskStorageWriter {
|
||||
task.updatedAt = Date.now();
|
||||
task.progress = 100;
|
||||
await this.saveTask(task);
|
||||
return normalizedResult;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,7 +344,7 @@ class TaskStorageWriter {
|
||||
/**
|
||||
* 批量导入任务(用于云同步恢复)
|
||||
* 只导入不存在的任务,已存在的跳过
|
||||
*
|
||||
*
|
||||
* @returns 成功导入的任务数量
|
||||
*/
|
||||
async importTasks(
|
||||
@@ -345,49 +362,18 @@ class TaskStorageWriter {
|
||||
|
||||
for (let i = 0; i < tasks.length; i += batchSize) {
|
||||
const batch = tasks.slice(i, i + batchSize);
|
||||
const result = await new Promise<{ imported: number; skipped: number }>((resolve, reject) => {
|
||||
const transaction = db.transaction(TASKS_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(TASKS_STORE);
|
||||
const result = await new Promise<{ imported: number; skipped: number }>(
|
||||
(resolve, reject) => {
|
||||
const transaction = db.transaction(TASKS_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(TASKS_STORE);
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let completed = 0;
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let completed = 0;
|
||||
|
||||
// 处理每个任务
|
||||
for (const task of batch) {
|
||||
if (options.replaceExisting) {
|
||||
const putRequest = store.put(task);
|
||||
putRequest.onsuccess = () => {
|
||||
imported++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
putRequest.onerror = () => {
|
||||
// 单个任务失败不影响其他任务
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先检查是否存在
|
||||
const getRequest = store.get(task.id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
if (getRequest.result) {
|
||||
// 任务已存在,跳过
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
} else {
|
||||
// 任务不存在,插入
|
||||
// 处理每个任务
|
||||
for (const task of batch) {
|
||||
if (options.replaceExisting) {
|
||||
const putRequest = store.put(task);
|
||||
putRequest.onsuccess = () => {
|
||||
imported++;
|
||||
@@ -404,23 +390,56 @@ class TaskStorageWriter {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
// 先检查是否存在
|
||||
const getRequest = store.get(task.id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
if (getRequest.result) {
|
||||
// 任务已存在,跳过
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
} else {
|
||||
// 任务不存在,插入
|
||||
const putRequest = store.put(task);
|
||||
putRequest.onsuccess = () => {
|
||||
imported++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
putRequest.onerror = () => {
|
||||
// 单个任务失败不影响其他任务
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
skipped++;
|
||||
completed++;
|
||||
if (completed === batch.length) {
|
||||
resolve({ imported, skipped });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
}
|
||||
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
);
|
||||
totalImported += result.imported;
|
||||
totalSkipped += result.skipped;
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
return { imported: totalImported, skipped: totalSkipped };
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getCachedBlob = vi.fn();
|
||||
const cacheMediaFromBlob = vi.fn();
|
||||
const generateVideoThumbnailFromBlob = vi.fn();
|
||||
const blobToDataUrl = vi.fn();
|
||||
const calculateBlobChecksum = vi.fn();
|
||||
const isCanvasFrameMostlyBlack = vi.fn();
|
||||
|
||||
vi.mock('@aitu/utils', () => ({
|
||||
blobToDataUrl,
|
||||
calculateBlobChecksum,
|
||||
generateVideoThumbnailFromBlob,
|
||||
isCanvasFrameMostlyBlack,
|
||||
}));
|
||||
|
||||
vi.mock('../unified-cache-service', () => ({
|
||||
unifiedCacheService: {
|
||||
getCachedBlob,
|
||||
cacheMediaFromBlob,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('video-cover-utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
|
||||
getCachedBlob.mockResolvedValue(
|
||||
new Blob(['video-binary'], { type: 'video/mp4' })
|
||||
);
|
||||
generateVideoThumbnailFromBlob.mockResolvedValue(
|
||||
new Blob(['cover-binary'], { type: 'image/jpeg' })
|
||||
);
|
||||
calculateBlobChecksum.mockResolvedValue('b'.repeat(64));
|
||||
isCanvasFrameMostlyBlack.mockReturnValue(false);
|
||||
cacheMediaFromBlob.mockImplementation(async (url: string) => url);
|
||||
blobToDataUrl.mockResolvedValue('data:image/jpeg;base64,cover');
|
||||
vi.spyOn(window.HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
});
|
||||
|
||||
it('imports a generated video cover into local assets and fills task result cover fields', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
item: {
|
||||
url: '/local-api/media/generated-cover',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { withGeneratedVideoCover } = await import('./video-cover-utils');
|
||||
const result = await withGeneratedVideoCover('task/video:1', {
|
||||
url: '/__aitu_cache__/video/task-1.mp4',
|
||||
format: 'mp4',
|
||||
size: 0,
|
||||
});
|
||||
|
||||
expect(result.previewImageUrl).toBe('/local-api/media/generated-cover');
|
||||
expect(result.thumbnailUrl).toBe('/local-api/media/generated-cover');
|
||||
expect(result.thumbnailUrls).toEqual(['/local-api/media/generated-cover']);
|
||||
expect(generateVideoThumbnailFromBlob).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
720,
|
||||
{ allowMostlyBlackFallback: false }
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/local-api/assets/import?filename=task-video-1-cover.jpg&purpose=video-cover®isterAsset=0',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'image/jpeg',
|
||||
},
|
||||
body: expect.any(Blob),
|
||||
})
|
||||
);
|
||||
expect(cacheMediaFromBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a stable cache URL when local asset import is unavailable', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('Not found', { status: 404 }))
|
||||
);
|
||||
|
||||
const { withGeneratedVideoCover } = await import('./video-cover-utils');
|
||||
const result = await withGeneratedVideoCover('task-2', {
|
||||
url: '/__aitu_cache__/video/task-2.mp4',
|
||||
format: 'mp4',
|
||||
size: 0,
|
||||
});
|
||||
|
||||
expect(result.previewImageUrl).toBe(
|
||||
`/__aitu_cache__/image/video-cover-${'b'.repeat(64)}.jpg`
|
||||
);
|
||||
expect(cacheMediaFromBlob).toHaveBeenCalledWith(
|
||||
`/__aitu_cache__/image/video-cover-${'b'.repeat(64)}.jpg`,
|
||||
expect.any(Blob),
|
||||
'image',
|
||||
{
|
||||
taskId: 'task-2',
|
||||
source: 'AI_GENERATED',
|
||||
role: 'video-cover',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not regenerate covers when the result already has a poster', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
} as unknown as typeof Image
|
||||
);
|
||||
|
||||
const { withGeneratedVideoCover } = await import('./video-cover-utils');
|
||||
const result = await withGeneratedVideoCover('task-3', {
|
||||
url: '/__aitu_cache__/video/task-3.mp4',
|
||||
format: 'mp4',
|
||||
size: 0,
|
||||
previewImageUrl: '/existing-cover.jpg',
|
||||
});
|
||||
|
||||
expect(result.previewImageUrl).toBe('/existing-cover.jpg');
|
||||
expect(generateVideoThumbnailFromBlob).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces an existing poster when the poster image is mostly black', async () => {
|
||||
isCanvasFrameMostlyBlack.mockReturnValueOnce(true);
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
} as unknown as typeof Image
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
item: {
|
||||
url: '/local-api/media/non-black-cover',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { withGeneratedVideoCover } = await import('./video-cover-utils');
|
||||
const result = await withGeneratedVideoCover('task-4', {
|
||||
url: '/__aitu_cache__/video/task-4.mp4',
|
||||
format: 'mp4',
|
||||
size: 0,
|
||||
previewImageUrl: '/black-cover.jpg',
|
||||
});
|
||||
|
||||
expect(result.previewImageUrl).toBe('/local-api/media/non-black-cover');
|
||||
expect(result.thumbnailUrl).toBe('/local-api/media/non-black-cover');
|
||||
expect(result.thumbnailUrls).toEqual(['/local-api/media/non-black-cover']);
|
||||
expect(generateVideoThumbnailFromBlob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a black poster when no non-black frame can be generated', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
isCanvasFrameMostlyBlack.mockReturnValueOnce(true);
|
||||
generateVideoThumbnailFromBlob.mockRejectedValueOnce(
|
||||
new Error('Failed to find a usable video frame')
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 640;
|
||||
naturalHeight = 360;
|
||||
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
} as unknown as typeof Image
|
||||
);
|
||||
|
||||
const { withGeneratedVideoCover } = await import('./video-cover-utils');
|
||||
const result = await withGeneratedVideoCover('task-5', {
|
||||
url: '/__aitu_cache__/video/task-5.mp4',
|
||||
format: 'mp4',
|
||||
size: 0,
|
||||
previewImageUrl: '/black-cover.jpg',
|
||||
thumbnailUrl: '/black-cover.jpg',
|
||||
thumbnailUrls: ['/black-cover.jpg'],
|
||||
});
|
||||
|
||||
expect(result.previewImageUrl).toBeUndefined();
|
||||
expect(result.thumbnailUrl).toBeUndefined();
|
||||
expect(result.thumbnailUrls).toBeUndefined();
|
||||
expect(generateVideoThumbnailFromBlob).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
720,
|
||||
{ allowMostlyBlackFallback: false }
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
blobToDataUrl,
|
||||
calculateBlobChecksum,
|
||||
generateVideoThumbnailFromBlob,
|
||||
isCanvasFrameMostlyBlack,
|
||||
} from '@aitu/utils';
|
||||
import { unifiedCacheService } from '../unified-cache-service';
|
||||
|
||||
export interface VideoCoverResult {
|
||||
coverUrl?: string;
|
||||
dataUrl?: string;
|
||||
persistedTo?: 'local-api' | 'cache' | 'data-url';
|
||||
}
|
||||
|
||||
interface LocalAssetImportResponse {
|
||||
item?: {
|
||||
url?: string;
|
||||
thumbnailPath?: string;
|
||||
localPath?: string;
|
||||
};
|
||||
asset?: {
|
||||
url?: string;
|
||||
thumbnailPath?: string;
|
||||
localPath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const VIDEO_COVER_MAX_SIZE = 720;
|
||||
|
||||
function hasBrowserVideoCoverSupport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof document !== 'undefined' &&
|
||||
typeof fetch !== 'undefined' &&
|
||||
typeof Blob !== 'undefined' &&
|
||||
typeof URL !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCoverFilename(taskId: string): string {
|
||||
const safeTaskId =
|
||||
taskId
|
||||
.trim()
|
||||
.replace(/[^\w.-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 96) || 'video-task';
|
||||
return `${safeTaskId}-cover.jpg`;
|
||||
}
|
||||
|
||||
function pickImportedAssetUrl(payload: LocalAssetImportResponse): string {
|
||||
const asset = payload.item || payload.asset;
|
||||
return asset?.url || asset?.thumbnailPath || '';
|
||||
}
|
||||
|
||||
function collectCoverUrls(result?: {
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
}): string[] {
|
||||
const candidates = [
|
||||
result?.previewImageUrl,
|
||||
result?.thumbnailUrl,
|
||||
...(result?.thumbnailUrls || []),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return candidates.reduce<string[]>((items, value) => {
|
||||
const url = typeof value === 'string' ? value.trim() : '';
|
||||
if (!url || seen.has(url)) {
|
||||
return items;
|
||||
}
|
||||
seen.add(url);
|
||||
items.push(url);
|
||||
return items;
|
||||
}, []);
|
||||
}
|
||||
|
||||
async function loadImage(url: string): Promise<HTMLImageElement | null> {
|
||||
if (!url || typeof Image === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(null);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function isImageUrlMostlyBlack(url: string): Promise<boolean> {
|
||||
const image = await loadImage(url);
|
||||
if (!image?.naturalWidth || !image.naturalHeight) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maxSide = 160;
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxSide / Math.max(image.naturalWidth, image.naturalHeight)
|
||||
);
|
||||
const width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
const height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return isCanvasFrameMostlyBlack(context, width, height);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasUsableVideoCover(result?: {
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
}): Promise<boolean> {
|
||||
return Boolean(await findUsableVideoCover(result));
|
||||
}
|
||||
|
||||
async function findUsableVideoCover(result?: {
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
}): Promise<string | null> {
|
||||
const coverUrls = collectCoverUrls(result);
|
||||
for (const coverUrl of coverUrls) {
|
||||
if (!(await isImageUrlMostlyBlack(coverUrl))) {
|
||||
return coverUrl;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchVideoBlob(videoUrl: string): Promise<Blob | null> {
|
||||
try {
|
||||
const cachedBlob = await unifiedCacheService.getCachedBlob(videoUrl);
|
||||
if (cachedBlob && cachedBlob.size > 0) {
|
||||
return cachedBlob;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[VideoCover] Failed to read cached video blob:', error);
|
||||
}
|
||||
|
||||
if (
|
||||
!videoUrl.startsWith('http://') &&
|
||||
!videoUrl.startsWith('https://') &&
|
||||
!videoUrl.startsWith('/') &&
|
||||
!videoUrl.startsWith('blob:') &&
|
||||
!videoUrl.startsWith('data:')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(videoUrl, {
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return blob.size > 0 ? blob : null;
|
||||
} catch (error) {
|
||||
console.warn('[VideoCover] Failed to fetch video for cover:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function importCoverToLocalApi(
|
||||
blob: Blob,
|
||||
taskId: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/local-api/assets/import?filename=${encodeURIComponent(
|
||||
normalizeCoverFilename(taskId)
|
||||
)}&purpose=video-cover®isterAsset=0`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': blob.type || 'image/jpeg',
|
||||
},
|
||||
body: blob,
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = (await response.json()) as LocalAssetImportResponse;
|
||||
return pickImportedAssetUrl(payload) || undefined;
|
||||
} catch (error) {
|
||||
console.warn('[VideoCover] Failed to import cover to local API:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheCoverBlob(
|
||||
blob: Blob,
|
||||
taskId: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const hash = await calculateBlobChecksum(blob);
|
||||
const cacheUrl = `/__aitu_cache__/image/video-cover-${hash}.jpg`;
|
||||
return await unifiedCacheService.cacheMediaFromBlob(
|
||||
cacheUrl,
|
||||
blob,
|
||||
'image',
|
||||
{
|
||||
taskId,
|
||||
source: 'AI_GENERATED',
|
||||
role: 'video-cover',
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[VideoCover] Failed to cache cover blob:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasVideoCover(result?: {
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
result?.previewImageUrl ||
|
||||
result?.thumbnailUrl ||
|
||||
result?.thumbnailUrls?.some(
|
||||
(url) => typeof url === 'string' && url.trim()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureVideoCoverForResult(
|
||||
taskId: string,
|
||||
result?: {
|
||||
url: string;
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
} | null,
|
||||
options: { checkExistingCover?: boolean } = {}
|
||||
): Promise<VideoCoverResult | null> {
|
||||
if (!result?.url || !hasBrowserVideoCoverSupport()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
options.checkExistingCover !== false &&
|
||||
(await hasUsableVideoCover(result))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoBlob = await fetchVideoBlob(result.url);
|
||||
if (!videoBlob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const coverBlob = await generateVideoThumbnailFromBlob(
|
||||
videoBlob,
|
||||
VIDEO_COVER_MAX_SIZE,
|
||||
{ allowMostlyBlackFallback: false }
|
||||
);
|
||||
const localAssetUrl = await importCoverToLocalApi(coverBlob, taskId);
|
||||
if (localAssetUrl) {
|
||||
return {
|
||||
coverUrl: localAssetUrl,
|
||||
persistedTo: 'local-api',
|
||||
};
|
||||
}
|
||||
|
||||
const cachedCoverUrl = await cacheCoverBlob(coverBlob, taskId);
|
||||
if (cachedCoverUrl) {
|
||||
return {
|
||||
coverUrl: cachedCoverUrl,
|
||||
persistedTo: 'cache',
|
||||
};
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(coverBlob);
|
||||
return {
|
||||
coverUrl: dataUrl,
|
||||
dataUrl,
|
||||
persistedTo: 'data-url',
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[VideoCover] Failed to generate video cover:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withGeneratedVideoCover<
|
||||
TResult extends
|
||||
| {
|
||||
url: string;
|
||||
previewImageUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailUrls?: string[];
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>(taskId: string, result: TResult): Promise<TResult> {
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const existingCoverUrl = await findUsableVideoCover(result);
|
||||
if (existingCoverUrl) {
|
||||
return {
|
||||
...result,
|
||||
previewImageUrl: existingCoverUrl,
|
||||
thumbnailUrl: existingCoverUrl,
|
||||
thumbnailUrls: [existingCoverUrl],
|
||||
} as TResult;
|
||||
}
|
||||
|
||||
const cover = await ensureVideoCoverForResult(taskId, result, {
|
||||
checkExistingCover: false,
|
||||
});
|
||||
if (!cover?.coverUrl) {
|
||||
if (hasVideoCover(result)) {
|
||||
return {
|
||||
...result,
|
||||
previewImageUrl: undefined,
|
||||
thumbnailUrl: undefined,
|
||||
thumbnailUrls: undefined,
|
||||
} as TResult;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
previewImageUrl: cover.coverUrl,
|
||||
thumbnailUrl: cover.coverUrl,
|
||||
thumbnailUrls: [cover.coverUrl],
|
||||
} as TResult;
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
VideoModelAdapter,
|
||||
} from './types';
|
||||
import { registerModelAdapter } from './registry';
|
||||
import { sendAdapterRequest } from './context';
|
||||
import {
|
||||
buildProviderContextFromAdapterContext,
|
||||
sendAdapterRequest,
|
||||
} from './context';
|
||||
import { downloadVideoContentToLocalUrl } from '../video-binding-utils';
|
||||
|
||||
const SEEDANCE_MODELS = [
|
||||
'seedance-1.5-pro',
|
||||
@@ -40,12 +44,21 @@ type SeedanceQueryResponse = {
|
||||
progress?: number;
|
||||
video_url?: string;
|
||||
url?: string;
|
||||
content_url?: string;
|
||||
data?: unknown;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
video?: unknown;
|
||||
videos?: unknown;
|
||||
video_urls?: unknown;
|
||||
seconds?: string;
|
||||
error?: string | { code: string; message: string };
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||
const DEFAULT_POLL_MAX_ATTEMPTS = 1080; // ~90 min
|
||||
const SEEDANCE_SUCCESS_STATUSES = new Set(['completed', 'succeeded']);
|
||||
const SEEDANCE_FAILURE_STATUSES = new Set(['failed', 'error']);
|
||||
|
||||
/**
|
||||
* 将逻辑模型 ID + 分辨率拼接为 API 实际模型名
|
||||
@@ -107,6 +120,115 @@ const extractErrorMessage = (
|
||||
return error.message || '视频生成失败';
|
||||
};
|
||||
|
||||
function normalizeStatus(status?: string): string {
|
||||
return String(status || '').toLowerCase();
|
||||
}
|
||||
|
||||
function isSeedanceSuccessStatus(status?: string): boolean {
|
||||
return SEEDANCE_SUCCESS_STATUSES.has(normalizeStatus(status));
|
||||
}
|
||||
|
||||
function isSeedanceFailureStatus(status?: string): boolean {
|
||||
return SEEDANCE_FAILURE_STATUSES.has(normalizeStatus(status));
|
||||
}
|
||||
|
||||
function normalizeActiveProgress(status?: string, progress?: number): number {
|
||||
if (isSeedanceSuccessStatus(status) || isSeedanceFailureStatus(status)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (typeof progress !== 'number' || Number.isNaN(progress)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(99, Math.max(0, Math.round(progress)));
|
||||
}
|
||||
|
||||
function extractStringUrl(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractSeedanceResultUrlFromValue(value: unknown): string | undefined {
|
||||
const directUrl = extractStringUrl(value);
|
||||
if (directUrl) {
|
||||
return directUrl;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const url = extractSeedanceResultUrlFromValue(item);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const urlKeys = [
|
||||
'video_url',
|
||||
'url',
|
||||
'content_url',
|
||||
'file_url',
|
||||
'download_url',
|
||||
'video',
|
||||
];
|
||||
|
||||
for (const key of urlKeys) {
|
||||
const url = extractStringUrl(record[key]);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const nestedKeys = [
|
||||
'data',
|
||||
'output',
|
||||
'result',
|
||||
'video',
|
||||
'videos',
|
||||
'video_urls',
|
||||
];
|
||||
for (const key of nestedKeys) {
|
||||
const url = extractSeedanceResultUrlFromValue(record[key]);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSeedanceResultUrl(
|
||||
status: SeedanceQueryResponse
|
||||
): string | undefined {
|
||||
return extractSeedanceResultUrlFromValue(status);
|
||||
}
|
||||
|
||||
async function resolveSeedanceResultUrl(
|
||||
context: AdapterContext,
|
||||
taskId: string,
|
||||
model: string,
|
||||
status: SeedanceQueryResponse
|
||||
): Promise<string> {
|
||||
const inlineUrl = extractSeedanceResultUrl(status);
|
||||
if (inlineUrl) {
|
||||
return inlineUrl;
|
||||
}
|
||||
|
||||
return downloadVideoContentToLocalUrl({
|
||||
videoId: taskId,
|
||||
provider: buildProviderContextFromAdapterContext(context),
|
||||
binding: context.binding,
|
||||
modelId: model,
|
||||
cacheKey: taskId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 data URL 或远程 URL 转为 Blob
|
||||
*/
|
||||
@@ -279,7 +401,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
|
||||
onSubmitted?.(taskId);
|
||||
|
||||
// 提交时已失败
|
||||
if (submitResult.status === 'failed' || submitResult.status === 'error') {
|
||||
if (isSeedanceFailureStatus(submitResult.status)) {
|
||||
throw new Error(extractErrorMessage(submitResult.error));
|
||||
}
|
||||
|
||||
@@ -302,20 +424,19 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
|
||||
const status = await querySeedanceVideo(context, taskId);
|
||||
consecutiveErrors = 0;
|
||||
|
||||
const progress =
|
||||
status.progress ??
|
||||
(status.status === 'failed'
|
||||
? 100
|
||||
: status.status === 'completed'
|
||||
? 100
|
||||
: 0);
|
||||
const progress = normalizeActiveProgress(
|
||||
status.status,
|
||||
status.progress
|
||||
);
|
||||
onProgress?.(progress, status.status);
|
||||
|
||||
if (status.status === 'completed' || status.status === 'succeeded') {
|
||||
const url = status.video_url || status.url;
|
||||
if (!url) {
|
||||
throw new Error('Seedance 结果缺少视频 URL');
|
||||
}
|
||||
if (isSeedanceSuccessStatus(status.status)) {
|
||||
const url = await resolveSeedanceResultUrl(
|
||||
context,
|
||||
taskId,
|
||||
actualModel,
|
||||
status
|
||||
);
|
||||
return {
|
||||
url,
|
||||
format: 'mp4',
|
||||
@@ -324,7 +445,7 @@ export const seedanceVideoAdapter: VideoModelAdapter = {
|
||||
};
|
||||
}
|
||||
|
||||
if (status.status === 'failed' || status.status === 'error') {
|
||||
if (isSeedanceFailureStatus(status.status)) {
|
||||
isBusinessFailure = true;
|
||||
throw new Error(extractErrorMessage(status.error));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
resolveAdapterForInvocation,
|
||||
} from './model-adapters';
|
||||
import { cacheRemoteUrl } from './media-executor/fallback-utils';
|
||||
import { withGeneratedVideoCover } from './media-executor/video-cover-utils';
|
||||
import {
|
||||
IMAGE_GENERATION_TIMEOUT_MS,
|
||||
STORAGE_LIMITS,
|
||||
@@ -77,8 +78,7 @@ import { MAX_VIDEO_GENERATION_PROMPT_LENGTH } from '../components/shared/workflo
|
||||
|
||||
function isComfyUiTaskStillRunningError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === 'ComfyUiTaskStillRunningError'
|
||||
error instanceof Error && error.name === 'ComfyUiTaskStillRunningError'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,12 +139,7 @@ function areStorageSyncValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
left &&
|
||||
right &&
|
||||
typeof left === 'object' &&
|
||||
typeof right === 'object'
|
||||
) {
|
||||
if (left && right && typeof left === 'object' && typeof right === 'object') {
|
||||
const leftJson = stableStringify(left);
|
||||
const rightJson = stableStringify(right);
|
||||
return leftJson !== undefined && leftJson === rightJson;
|
||||
@@ -153,7 +148,10 @@ function areStorageSyncValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasStorageTaskChanges(task: Task, storageTask: Partial<Task>): boolean {
|
||||
function hasStorageTaskChanges(
|
||||
task: Task,
|
||||
storageTask: Partial<Task>
|
||||
): boolean {
|
||||
return STORAGE_SYNC_FIELDS.some((field) => {
|
||||
if (!(field in storageTask)) {
|
||||
return false;
|
||||
@@ -242,10 +240,7 @@ function trackTaskAnalytics(
|
||||
|
||||
function isTrackedTerminalTaskStatus(
|
||||
status: TaskStatus
|
||||
): status is
|
||||
| TaskStatus.COMPLETED
|
||||
| TaskStatus.FAILED
|
||||
| TaskStatus.CANCELLED {
|
||||
): status is TaskStatus.COMPLETED | TaskStatus.FAILED | TaskStatus.CANCELLED {
|
||||
return (
|
||||
status === TaskStatus.COMPLETED ||
|
||||
status === TaskStatus.FAILED ||
|
||||
@@ -295,6 +290,26 @@ function trackTerminalTaskAnalytics(
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTaskWithResultCover(
|
||||
taskId: string,
|
||||
result: NonNullable<Task['result']>
|
||||
): Promise<NonNullable<Task['result']>> {
|
||||
return (await taskStorageWriter.completeTask(
|
||||
taskId,
|
||||
result as SWTask['result']
|
||||
)) as NonNullable<Task['result']>;
|
||||
}
|
||||
|
||||
async function normalizeCompletedTaskResult(
|
||||
task: Task,
|
||||
result: Task['result']
|
||||
): Promise<Task['result']> {
|
||||
if (!result || task.type !== TaskType.VIDEO) {
|
||||
return result;
|
||||
}
|
||||
return withGeneratedVideoCover(task.id, result);
|
||||
}
|
||||
|
||||
function normalizeImageDataUrl(
|
||||
value: string,
|
||||
fallbackMimeType = 'image/png'
|
||||
@@ -1142,10 +1157,14 @@ class TaskQueueService {
|
||||
result.task &&
|
||||
!this.shouldSkipExecutionWriteback(task.id)
|
||||
) {
|
||||
const finalResult = await normalizeCompletedTaskResult(
|
||||
localTask,
|
||||
result.task.result
|
||||
);
|
||||
const finalTask: Task = {
|
||||
...localTask,
|
||||
status: result.task.status as TaskStatus,
|
||||
result: result.task.result,
|
||||
result: finalResult,
|
||||
error: result.task.error,
|
||||
completedAt: result.task.completedAt,
|
||||
updatedAt: Date.now(),
|
||||
@@ -1232,7 +1251,7 @@ class TaskQueueService {
|
||||
...payload.resultExtras,
|
||||
};
|
||||
|
||||
await taskStorageWriter.completeTask(task.id, result);
|
||||
const persistedResult = await completeTaskWithResultCover(task.id, result);
|
||||
|
||||
if (this.shouldSkipExecutionWriteback(task.id)) {
|
||||
return;
|
||||
@@ -1244,7 +1263,7 @@ class TaskQueueService {
|
||||
...previousTask,
|
||||
status: TaskStatus.COMPLETED,
|
||||
progress: 100,
|
||||
result,
|
||||
result: persistedResult,
|
||||
executionPhase: undefined,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
@@ -2045,6 +2064,19 @@ class TaskQueueService {
|
||||
|
||||
this.emitEvent('taskUpdated', updatedTask);
|
||||
|
||||
if (
|
||||
status === TaskStatus.COMPLETED &&
|
||||
updatedTask.type === TaskType.VIDEO &&
|
||||
updatedTask.result
|
||||
) {
|
||||
this.ensureCompletedVideoTaskCover(updatedTask).catch((error) => {
|
||||
console.warn(
|
||||
`[TaskQueueService] Failed to generate cover for completed video task ${taskId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// console.log(`[TaskQueueService] Updated task ${taskId} to ${status}`);
|
||||
const enteredTerminalStatus =
|
||||
isTrackedTerminalTaskStatus(status) && task.status !== status;
|
||||
@@ -2055,6 +2087,34 @@ class TaskQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCompletedVideoTaskCover(task: Task): Promise<void> {
|
||||
const resultWithCover = await normalizeCompletedTaskResult(
|
||||
task,
|
||||
task.result
|
||||
);
|
||||
if (!resultWithCover || resultWithCover === task.result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTask = this.tasks.get(task.id);
|
||||
if (
|
||||
!currentTask ||
|
||||
currentTask.status !== TaskStatus.COMPLETED ||
|
||||
currentTask.result !== task.result
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTask: Task = {
|
||||
...currentTask,
|
||||
result: resultWithCover,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
this.tasks.set(task.id, updatedTask);
|
||||
this.persistTask(updatedTask);
|
||||
this.emitEvent('taskUpdated', updatedTask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a task's progress
|
||||
*
|
||||
@@ -2107,9 +2167,9 @@ class TaskQueueService {
|
||||
}
|
||||
|
||||
async findImageTaskByResultUrl(imageUrl: string): Promise<Task | undefined> {
|
||||
const memoryMatch = this
|
||||
.getAllTasks()
|
||||
.find((task) => imageTaskMatchesUrl(task, imageUrl));
|
||||
const memoryMatch = this.getAllTasks().find((task) =>
|
||||
imageTaskMatchesUrl(task, imageUrl)
|
||||
);
|
||||
if (memoryMatch) {
|
||||
return this.getCompleteTask(memoryMatch.id);
|
||||
}
|
||||
@@ -2182,10 +2242,7 @@ class TaskQueueService {
|
||||
*
|
||||
* @param taskId - The task ID to retry
|
||||
*/
|
||||
retryTask(
|
||||
taskId: string,
|
||||
options: { allowCompleted?: boolean } = {}
|
||||
): void {
|
||||
retryTask(taskId: string, options: { allowCompleted?: boolean } = {}): void {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
console.warn(`[TaskQueueService] Task ${taskId} not found`);
|
||||
|
||||
@@ -113,6 +113,60 @@ const WORKSPACE_DB_CONFIG = {
|
||||
} as const;
|
||||
|
||||
const STATE_KEY = 'workspace_state';
|
||||
const DEFAULT_LOCAL_API_ORIGIN = 'http://127.0.0.1:48177';
|
||||
|
||||
function localApiBaseUrl(): string {
|
||||
const runtimeBase =
|
||||
typeof window === 'undefined'
|
||||
? ''
|
||||
: (
|
||||
window as Window & {
|
||||
trueGrowthRuntime?: { localApiBaseUrl?: string };
|
||||
}
|
||||
).trueGrowthRuntime?.localApiBaseUrl;
|
||||
if (runtimeBase) {
|
||||
return runtimeBase.replace(/\/+$/, '');
|
||||
}
|
||||
return `${DEFAULT_LOCAL_API_ORIGIN}/local-api`;
|
||||
}
|
||||
|
||||
async function requestWorkspaceJson<T>(
|
||||
pathname: string,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> {
|
||||
if (typeof fetch === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${localApiBaseUrl()}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkspaceRecord<T extends { id: string }>(
|
||||
pathname: string,
|
||||
item: T
|
||||
): Promise<void> {
|
||||
await requestWorkspaceJson(pathname, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ item }),
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteWorkspaceRecord(pathname: string): Promise<void> {
|
||||
await requestWorkspaceJson(pathname, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for browser idle time
|
||||
@@ -318,19 +372,40 @@ class WorkspaceStorageService {
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.getFoldersStore().setItem(folder.id, folder);
|
||||
await saveWorkspaceRecord('/workspace/folders', folder);
|
||||
}
|
||||
|
||||
async loadFolder(id: string): Promise<Folder | null> {
|
||||
await this.ensureInitialized();
|
||||
const remote = await requestWorkspaceJson<{ ok: boolean; items: Folder[] }>(
|
||||
'/workspace/folders'
|
||||
);
|
||||
const remoteFolder = remote?.items?.find((item) => item.id === id);
|
||||
if (remoteFolder) {
|
||||
await this.getFoldersStore().setItem(remoteFolder.id, remoteFolder);
|
||||
return remoteFolder;
|
||||
}
|
||||
return this.getFoldersStore().getItem<Folder>(id);
|
||||
}
|
||||
|
||||
async loadAllFolders(): Promise<Folder[]> {
|
||||
await this.ensureInitialized();
|
||||
const remote = await requestWorkspaceJson<{ ok: boolean; items: Folder[] }>(
|
||||
'/workspace/folders'
|
||||
);
|
||||
if (Array.isArray(remote?.items) && remote.items.length > 0) {
|
||||
await Promise.all(
|
||||
remote.items.map((folder) => this.getFoldersStore().setItem(folder.id, folder))
|
||||
);
|
||||
return remote.items.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
const folders: Folder[] = [];
|
||||
await this.getFoldersStore().iterate<Folder, void>((value) => {
|
||||
if (value && value.id) folders.push(value);
|
||||
});
|
||||
await Promise.all(
|
||||
folders.map((folder) => saveWorkspaceRecord('/workspace/folders', folder))
|
||||
);
|
||||
// Wait for browser idle time after IndexedDB operation
|
||||
await waitForIdle();
|
||||
return folders.sort((a, b) => a.order - b.order);
|
||||
@@ -339,6 +414,7 @@ class WorkspaceStorageService {
|
||||
async deleteFolder(id: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.getFoldersStore().removeItem(id);
|
||||
await deleteWorkspaceRecord(`/workspace/folders/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// ========== Board Operations ==========
|
||||
@@ -346,13 +422,17 @@ class WorkspaceStorageService {
|
||||
async saveBoard(board: Board): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.getBoardsStore().setItem(board.id, board);
|
||||
await saveWorkspaceRecord('/workspace/boards', board);
|
||||
}
|
||||
|
||||
async loadBoard(id: string): Promise<Board | null> {
|
||||
await this.ensureInitialized();
|
||||
let board: Board | null;
|
||||
try {
|
||||
board = await this.getBoardsStore().getItem<Board>(id);
|
||||
const remote = await requestWorkspaceJson<{ ok: boolean; item: Board }>(
|
||||
`/workspace/boards/${encodeURIComponent(id)}`
|
||||
);
|
||||
board = remote?.item || (await this.getBoardsStore().getItem<Board>(id));
|
||||
} catch (error) {
|
||||
console.error(`[Storage] Failed to load board ${id} from IndexedDB:`, error);
|
||||
return null;
|
||||
@@ -373,11 +453,27 @@ class WorkspaceStorageService {
|
||||
console.error(`[Migration] Board ${id}: Failed to migrate`, error);
|
||||
}
|
||||
}
|
||||
if (board?.id) {
|
||||
await this.getBoardsStore().setItem(board.id, board);
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
async loadAllBoards(): Promise<Board[]> {
|
||||
await this.ensureInitialized();
|
||||
const remote = await requestWorkspaceJson<{ ok: boolean; items: Board[] }>(
|
||||
'/workspace/boards'
|
||||
);
|
||||
if (Array.isArray(remote?.items) && remote.items.length > 0) {
|
||||
const boards = remote.items;
|
||||
for (const board of boards) {
|
||||
if (board.elements) {
|
||||
board.elements = migrateElementsFillData(board.elements);
|
||||
}
|
||||
await this.getBoardsStore().setItem(board.id, board);
|
||||
}
|
||||
return boards.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
const boards: Board[] = [];
|
||||
await this.getBoardsStore().iterate<Board, void>((value) => {
|
||||
if (value && value.id) {
|
||||
@@ -388,6 +484,9 @@ class WorkspaceStorageService {
|
||||
boards.push(value);
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
boards.map((board) => saveWorkspaceRecord('/workspace/boards', board))
|
||||
);
|
||||
// Wait for browser idle time after IndexedDB operation
|
||||
await waitForIdle();
|
||||
|
||||
@@ -415,9 +514,31 @@ class WorkspaceStorageService {
|
||||
*/
|
||||
async loadAllBoardMetadata(): Promise<BoardMetadata[]> {
|
||||
await this.ensureInitialized();
|
||||
const remote = await requestWorkspaceJson<{ ok: boolean; items: Board[] }>(
|
||||
'/workspace/boards'
|
||||
);
|
||||
if (Array.isArray(remote?.items) && remote.items.length > 0) {
|
||||
await Promise.all(
|
||||
remote.items.map((board) => this.getBoardsStore().setItem(board.id, board))
|
||||
);
|
||||
return remote.items
|
||||
.map((value) => ({
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
folderId: value.folderId,
|
||||
order: value.order,
|
||||
viewport: value.viewport,
|
||||
theme: value.theme,
|
||||
createdAt: value.createdAt,
|
||||
updatedAt: value.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
const boards: BoardMetadata[] = [];
|
||||
const fullBoardsForSync: Board[] = [];
|
||||
await this.getBoardsStore().iterate<Board, void>((value) => {
|
||||
if (value && value.id) {
|
||||
fullBoardsForSync.push(value);
|
||||
// 只提取元数据,不包含 elements
|
||||
boards.push({
|
||||
id: value.id,
|
||||
@@ -431,6 +552,11 @@ class WorkspaceStorageService {
|
||||
});
|
||||
}
|
||||
});
|
||||
await Promise.all(
|
||||
fullBoardsForSync.map((board) =>
|
||||
saveWorkspaceRecord('/workspace/boards', board)
|
||||
)
|
||||
);
|
||||
// Wait for browser idle time after IndexedDB operation
|
||||
await waitForIdle();
|
||||
|
||||
@@ -457,6 +583,7 @@ class WorkspaceStorageService {
|
||||
async deleteBoard(id: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.getBoardsStore().removeItem(id);
|
||||
await deleteWorkspaceRecord(`/workspace/boards/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async deleteFolderBoards(folderId: string): Promise<void> {
|
||||
@@ -470,11 +597,30 @@ class WorkspaceStorageService {
|
||||
async saveState(state: WorkspaceState): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.getStateStore().setItem(STATE_KEY, state);
|
||||
await saveWorkspaceRecord('/workspace/state', {
|
||||
id: 'workspace-state',
|
||||
...state,
|
||||
});
|
||||
}
|
||||
|
||||
async loadState(): Promise<WorkspaceState> {
|
||||
await this.ensureInitialized();
|
||||
const remote = await requestWorkspaceJson<{
|
||||
ok: boolean;
|
||||
item: (WorkspaceState & { id?: string }) | null;
|
||||
}>('/workspace/state');
|
||||
if (remote?.item) {
|
||||
const { id: _id, ...state } = remote.item;
|
||||
await this.getStateStore().setItem(STATE_KEY, state);
|
||||
return state;
|
||||
}
|
||||
const state = await this.getStateStore().getItem<WorkspaceState>(STATE_KEY);
|
||||
if (state) {
|
||||
await saveWorkspaceRecord('/workspace/state', {
|
||||
id: 'workspace-state',
|
||||
...state,
|
||||
});
|
||||
}
|
||||
return (
|
||||
state || {
|
||||
currentBoardId: null,
|
||||
@@ -499,6 +645,7 @@ class WorkspaceStorageService {
|
||||
this.getBoardsStore().clear(),
|
||||
this.getStateStore().clear(),
|
||||
]);
|
||||
await requestWorkspaceJson('/workspace/clear', { method: 'POST' });
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface Asset {
|
||||
url: string; // Blob URL for display
|
||||
name: string; // 用户可见名称
|
||||
mimeType: string; // MIME类型 (image/jpeg, video/mp4, etc.)
|
||||
localPath?: string; // 本机运行时文件路径(仅桌面/本地服务资产)
|
||||
|
||||
// 元数据
|
||||
createdAt: number; // Unix timestamp (ms)
|
||||
@@ -364,13 +365,22 @@ export interface MediaLibraryModalProps {
|
||||
mode?: SelectionMode;
|
||||
filterType?: AssetType;
|
||||
filterCategory?: AssetCategory;
|
||||
extraAssets?: Asset[];
|
||||
onSelect?: (asset: Asset) => void | Promise<void>;
|
||||
/** 批量选择回调(素材库批量选择模式下使用) */
|
||||
onSelectMultiple?: (assets: Asset[]) => void | Promise<void>;
|
||||
/** 自定义删除逻辑,用于混合本地运行时资产和浏览器资产 */
|
||||
onDeleteAsset?: (asset: Asset) => Promise<boolean | void> | boolean | void;
|
||||
/** 自定义选择按钮文本,默认为"使用到画板" */
|
||||
selectButtonText?: string;
|
||||
/** 自定义批量操作按钮文本 */
|
||||
batchSelectButtonText?: string;
|
||||
/** 打开弹窗时直接进入多选模式 */
|
||||
defaultSelectionModeActive?: boolean;
|
||||
/** 多选详情面板标题 */
|
||||
selectionModeTitle?: string;
|
||||
/** 多选详情面板说明 */
|
||||
selectionModeDescription?: string;
|
||||
}
|
||||
|
||||
export interface AssetGridItemProps {
|
||||
@@ -414,6 +424,12 @@ export interface MediaLibraryInspectorProps {
|
||||
selecting?: boolean;
|
||||
/** 自定义选择按钮文本,默认为"使用到画板" */
|
||||
selectButtonText?: string;
|
||||
/** 自定义批量操作按钮文本 */
|
||||
batchSelectButtonText?: string;
|
||||
/** 多选详情面板标题 */
|
||||
selectionModeTitle?: string;
|
||||
/** 多选详情面板说明 */
|
||||
selectionModeDescription?: string;
|
||||
}
|
||||
|
||||
export interface MediaLibraryGridProps {
|
||||
@@ -426,10 +442,14 @@ export interface MediaLibraryGridProps {
|
||||
onPublishAsset?: (asset: Asset) => void;
|
||||
onFileUpload?: (files: FileList) => void;
|
||||
onUploadClick?: () => void;
|
||||
onDeleteAsset?: (asset: Asset) => Promise<boolean | void> | boolean | void;
|
||||
storageStatus?: StorageStatus | null;
|
||||
onSelectionChange?: (assets: Asset[], isSelectionMode: boolean) => void;
|
||||
previewOnClick?: boolean;
|
||||
disableHoverPlayback?: boolean;
|
||||
gridAspectRatio?: number;
|
||||
thumbnailFit?: 'cover' | 'contain';
|
||||
defaultSelectionModeActive?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaLibraryStorageBarProps {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
TaskType,
|
||||
type Task,
|
||||
} from '../../types/task.types';
|
||||
import { isResumableAsyncImageTask } from '../task-utils';
|
||||
import { getTaskTimelineItems, isResumableAsyncImageTask } from '../task-utils';
|
||||
|
||||
function createImageTask(overrides: Partial<Task> = {}): Task {
|
||||
const now = Date.now();
|
||||
@@ -70,4 +70,50 @@ describe('task-utils', () => {
|
||||
expect(isResumableAsyncImageTask(task)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskTimelineItems', () => {
|
||||
it('labels completed tasks with created and completed times', () => {
|
||||
const createdAt = new Date(2024, 2, 10, 0, 0, 0).getTime();
|
||||
const task = createImageTask({
|
||||
status: TaskStatus.COMPLETED,
|
||||
createdAt,
|
||||
updatedAt: createdAt + 2000,
|
||||
completedAt: createdAt + 3000,
|
||||
});
|
||||
|
||||
expect(getTaskTimelineItems(task)).toEqual([
|
||||
{ label: '创建', value: '2024-03-10 00:00:00' },
|
||||
{ label: '完成', value: '2024-03-10 00:00:03' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the update time for completed tasks without completedAt', () => {
|
||||
const createdAt = new Date(2024, 2, 10, 0, 0, 0).getTime();
|
||||
const task = createImageTask({
|
||||
status: TaskStatus.COMPLETED,
|
||||
createdAt,
|
||||
updatedAt: createdAt + 2000,
|
||||
completedAt: undefined,
|
||||
});
|
||||
|
||||
expect(getTaskTimelineItems(task)).toEqual([
|
||||
{ label: '创建', value: '2024-03-10 00:00:00' },
|
||||
{ label: '完成', value: '2024-03-10 00:00:02' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels failed tasks with the failure update time', () => {
|
||||
const createdAt = new Date(2024, 2, 10, 0, 0, 0).getTime();
|
||||
const task = createImageTask({
|
||||
status: TaskStatus.FAILED,
|
||||
createdAt,
|
||||
updatedAt: createdAt + 4000,
|
||||
});
|
||||
|
||||
expect(getTaskTimelineItems(task)).toEqual([
|
||||
{ label: '创建', value: '2024-03-10 00:00:00' },
|
||||
{ label: '失败', value: '2024-03-10 00:00:04' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,3 +143,66 @@ export function getTaskElapsedTime(task: Task): number {
|
||||
* formatDateTime(Date.now()) // Returns "2025-12-18 14:30:25"
|
||||
*/
|
||||
export const formatDateTime = formatDate;
|
||||
|
||||
export interface TaskTimelineItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function isValidTimestamp(timestamp: number | undefined): timestamp is number {
|
||||
return (
|
||||
typeof timestamp === 'number' &&
|
||||
Number.isFinite(timestamp) &&
|
||||
timestamp > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the labeled absolute times shown in task-center rows.
|
||||
*/
|
||||
export function getTaskTimelineItems(task: Task): TaskTimelineItem[] {
|
||||
const items: TaskTimelineItem[] = [];
|
||||
|
||||
if (isValidTimestamp(task.createdAt)) {
|
||||
items.push({
|
||||
label: '创建',
|
||||
value: formatDateTime(task.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
const completedAt = isValidTimestamp(task.completedAt)
|
||||
? task.completedAt
|
||||
: task.updatedAt;
|
||||
if (task.status === TaskStatus.COMPLETED && isValidTimestamp(completedAt)) {
|
||||
items.push({
|
||||
label: '完成',
|
||||
value: formatDateTime(completedAt),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (
|
||||
(task.status === TaskStatus.FAILED ||
|
||||
task.status === TaskStatus.CANCELLED) &&
|
||||
isValidTimestamp(task.updatedAt)
|
||||
) {
|
||||
items.push({
|
||||
label: task.status === TaskStatus.FAILED ? '失败' : '取消',
|
||||
value: formatDateTime(task.updatedAt),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
if (
|
||||
task.status === TaskStatus.PROCESSING &&
|
||||
isValidTimestamp(task.updatedAt) &&
|
||||
task.updatedAt !== task.createdAt
|
||||
) {
|
||||
items.push({
|
||||
label: '更新',
|
||||
value: formatDateTime(task.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user