Sync latest TrueGrowth updates
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
jiam
2026-07-08 02:03:18 +08:00
parent 52636c91ae
commit 5119ac0ef8
102 changed files with 20985 additions and 1760 deletions

View File

@@ -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(
() =>

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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'
);
});
});

View File

@@ -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
);
}
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}
}
// ========================================
// 移动端详情抽屉
// ========================================

View File

@@ -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>
)}

View File

@@ -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,
]

View File

@@ -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);
}

View File

@@ -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'
);
});
});
});

View File

@@ -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
);
};

View File

@@ -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>

View File

@@ -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 &&

View File

@@ -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

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;
}

View File

@@ -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',
});
});
});

View File

@@ -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(),
});

View File

@@ -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 };

View File

@@ -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&registerAsset=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();
});
});

View File

@@ -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&registerAsset=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;
}

View File

@@ -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));
}

View File

@@ -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`);

View File

@@ -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> {

View File

@@ -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 {

View File

@@ -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' },
]);
});
});
});

View File

@@ -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;
}