fix(design): connect video preparation and playable history

This commit is contained in:
2026-09-16 15:42:39 +08:00
parent 4321c77613
commit 1acca836fe
21 changed files with 900 additions and 115 deletions

View File

@@ -8,10 +8,12 @@ export function DesignAssetThumbnail({
asset,
className,
alt,
controls = false,
}: {
asset: DesignAsset | null;
className?: string;
alt?: string;
controls?: boolean;
}) {
const [resolution, setResolution] = useState<{
contentPath: string | null;
@@ -61,6 +63,8 @@ export function DesignAssetThumbnail({
aria-label={alt ?? '视频作品预览'}
muted
preload="metadata"
controls={controls}
playsInline
/>
);
}

View File

@@ -17,7 +17,11 @@ import {
type DesignAssistantActivity,
useImageWorkspaceStore,
} from '@/stores/image-workspace';
import type { DesignCompilationIssue, DesignWorkspace } from '../../../shared/image-workspace';
import type {
DesignCompilationIssue,
DesignGenerationOptions,
DesignWorkspace,
} from '../../../shared/image-workspace';
import { DesignPendingOperationsPanel } from './DesignProductionPanel';
import { DesignPlanHistory } from './DesignPlanHistory';
import { YouthCreationCard } from './YouthCreationCard';
@@ -135,11 +139,16 @@ export function DesignConversationPane({
workspace,
quoteBlockers,
generationAvailable,
generationOptions,
showPlanHistory = true,
}: {
workspace: DesignWorkspace;
quoteBlockers: DesignCompilationIssue[];
generationAvailable: boolean;
generationOptions?: {
image: DesignGenerationOptions;
video: DesignGenerationOptions;
};
showPlanHistory?: boolean;
}) {
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
@@ -340,6 +349,7 @@ export function DesignConversationPane({
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
generationOptions={generationOptions}
/>
)}

View File

@@ -2,9 +2,12 @@ import { ChevronDown, Clock3, Download, Film, ImageIcon, Loader2 } from 'lucide-
import { toast } from 'sonner';
import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { DesignImagePreviewButton } from './DesignImagePreview';
import { createVideoFirstFrameOperations } from './video-first-frame';
import { DesignVideoPreviewButton } from './DesignVideoPreview';
import { youthTaskIssueMessage } from './youth-issue-copy';
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
@@ -36,6 +39,22 @@ function taskDescription(task: DesignGenerationTask): string {
}
export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace }) {
const prepareGeneration = useImageWorkspaceStore((state) => state.prepareGeneration);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const currentMedium = workspace.form.specification.values.intent.media;
const preparing = Object.values(pendingOperations).some((operation) => (
operation.command.workspaceId === workspace.workspace.workspaceId
));
const handleUseAsFirstFrame = async (asset: DesignWorkspace['assets'][number]) => {
if (preparing || currentMedium !== 'video') return;
try {
const firstFrame = createVideoFirstFrameOperations(workspace, asset);
await prepareGeneration(firstFrame.operations);
toast.success('视频开始画面已选好');
} catch {
toast.error('这张图片暂时不能用于视频,请检查制作方案');
}
};
const tasks = [...workspace.tasks].sort(
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
);
@@ -111,13 +130,34 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
<div className="mt-3 flex flex-wrap gap-2">
{resultAssets.map((asset) => (
<div key={asset.assetId} className="group/result relative">
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
<DesignAssetThumbnail
asset={asset}
className={asset.mediaType === 'video' ? 'h-28 w-44 rounded-xl' : 'h-20 w-20 rounded-xl'}
controls={asset.mediaType === 'video'}
/>
{asset.mediaType === 'image' && <DesignImagePreviewButton asset={asset} />}
{asset.mediaType === 'video' && <DesignVideoPreviewButton asset={asset} />}
{asset.mediaType === 'image' && currentMedium === 'video' && (
<button
type="button"
className="absolute left-1 right-1 top-1 min-h-10 rounded-lg bg-background/90 px-1 py-1 text-[10px] font-medium text-foreground shadow-sm hover:text-brand disabled:opacity-40"
disabled={preparing}
onClick={(event) => {
event.preventDefault();
void handleUseAsFirstFrame(asset);
}}
>
用这张图做视频
</button>
)}
<button
type="button"
aria-label="保存作品到电脑"
title="保存作品到电脑"
className="absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
className={cn(
'absolute right-1 flex items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground',
asset.mediaType === 'video' ? 'top-1 h-10 w-10' : 'bottom-1 h-7 w-7',
)}
onClick={(event) => {
event.preventDefault();
void saveImageWorkspaceAsset(asset).catch(() => {

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { Film, Loader2, Maximize2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
import type { DesignAsset } from '../../../shared/image-workspace';
function VideoPreviewContent({ asset }: { asset: DesignAsset }) {
const [source, setSource] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => { if (active) setSource(url); })
.catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, [asset.contentPath]);
return (
<>
<header className="flex items-center justify-between gap-3 border-b border-border/70 px-4 py-3">
<div className="min-w-0">
<DialogTitle className="text-base">视频预览</DialogTitle>
<DialogDescription className="mt-1 text-xs">
{asset.width} × {asset.height}
{asset.durationMilliseconds ? ` · ${(asset.durationMilliseconds / 1000).toFixed(1)} 秒` : ''}
</DialogDescription>
</div>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon" aria-label="关闭视频预览">
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</DialogClose>
</header>
<div className="relative flex min-h-0 items-center justify-center overflow-auto bg-black/90 p-4" data-testid="design-video-preview-viewport">
{failed ? (
<div role="alert" className="flex flex-col items-center justify-center gap-3 p-6 text-sm text-white/75">
<Film className="h-8 w-8" aria-hidden="true" />
视频暂时无法加载,请关闭后重试。
</div>
) : (
<>
{!loaded && (
<div role="status" className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-white/75">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
正在加载视频…
</div>
)}
{source && (
<video
src={source}
controls
autoPlay
playsInline
className="max-h-full max-w-full object-contain"
onLoadedMetadata={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
)}
</>
)}
</div>
</>
);
}
export function DesignVideoPreviewButton({ asset }: { asset: DesignAsset }) {
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
aria-label="放大查看视频"
title="放大查看视频"
className="absolute top-1 left-1 flex h-10 w-10 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</DialogTrigger>
<DialogContent className="h-[85vh] max-w-[min(94vw,100rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
<VideoPreviewContent key={asset.contentPath} asset={asset} />
</DialogContent>
</Dialog>
);
}

View File

@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignCompilationIssue,
DesignGenerationOptions,
DesignSpecificationValues,
DesignUserFieldOperation,
DesignWorkspace,
@@ -38,6 +39,7 @@ import {
referenceToken,
removeReferenceTokenAndShift,
} from './reference-tokens';
import { createVideoFirstFrameOperations, creatorPromptForWorkspace } from './video-first-frame';
import { youthCompilationIssueMessage } from './youth-issue-copy';
type ReferenceValue = DesignSpecificationValues['references'][number];
@@ -47,6 +49,10 @@ export type YouthCreationCardProps = {
workspace: DesignWorkspace;
quoteBlockers: DesignCompilationIssue[];
generationAvailable: boolean;
generationOptions?: {
image: DesignGenerationOptions;
video: DesignGenerationOptions;
};
onOpenFineTune?: () => void;
onFocusComposer?: () => void;
onQuoteOffered?: () => void;
@@ -66,17 +72,24 @@ function createReferenceId(): string {
return `reference-${random}`.slice(0, 64);
}
function canonicalCreatorPrompt(workspace: DesignWorkspace): string {
const values = workspace.form.specification.values;
return values.content.concept?.trim()
|| values.content.narrative?.trim()
|| [
...values.content.subjects.map((subject) => subject.description.trim()),
values.content.action?.trim(),
values.content.environment?.trim(),
values.content.emotional_tone?.trim(),
].filter((value): value is string => Boolean(value)).join(',');
}
const UNAVAILABLE_GENERATION_OPTIONS: Record<'image' | 'video', DesignGenerationOptions> = {
image: {
available: false,
supportedAspectRatios: [],
supportedDurationSeconds: [],
maxOutputCount: 1,
requiresFirstFrame: false,
supportedReferenceRoles: [],
},
video: {
available: false,
supportedAspectRatios: [],
supportedDurationSeconds: [],
maxOutputCount: 1,
requiresFirstFrame: true,
supportedReferenceRoles: [],
},
};
function Stepper({
label,
@@ -84,6 +97,7 @@ function Stepper({
suffix,
minimum,
maximum,
allowedValues,
disabled,
onChange,
}: {
@@ -92,9 +106,15 @@ function Stepper({
suffix?: string;
minimum: number;
maximum: number;
allowedValues?: number[];
disabled: boolean;
onChange: (value: number) => void;
}) {
const values = allowedValues
? [...new Set(allowedValues)].sort((left, right) => left - right)
: Array.from({ length: Math.max(0, maximum - minimum + 1) }, (_, index) => minimum + index);
const previous = values.filter((item) => item < value).at(-1) ?? values[0] ?? minimum;
const next = values.find((item) => item > value) ?? values.at(-1) ?? maximum;
return (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">{label}</span>
@@ -103,8 +123,8 @@ function Stepper({
type="button"
aria-label={`减少${label}`}
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
disabled={disabled || value <= minimum}
onClick={() => onChange(Math.max(minimum, value - 1))}
disabled={disabled || values.length === 0 || value <= (values[0] ?? minimum)}
onClick={() => onChange(previous)}
>
<Minus className="h-3.5 w-3.5" />
</button>
@@ -115,8 +135,8 @@ function Stepper({
type="button"
aria-label={`增加${label}`}
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
disabled={disabled || value >= maximum}
onClick={() => onChange(Math.min(maximum, value + 1))}
disabled={disabled || values.length === 0 || value >= (values.at(-1) ?? maximum)}
onClick={() => onChange(next)}
>
<Plus className="h-3.5 w-3.5" />
</button>
@@ -129,14 +149,16 @@ export function YouthCreationCard({
workspace,
quoteBlockers,
generationAvailable,
generationOptions,
onQuoteOffered,
}: YouthCreationCardProps) {
const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations);
const prepareGeneration = useImageWorkspaceStore((state) => state.prepareGeneration);
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const values = workspace.form.specification.values;
const canonicalPrompt = canonicalCreatorPrompt(workspace);
const canonicalPrompt = creatorPromptForWorkspace(workspace);
const offeredQuote = workspace.form.activeQuotes.find((quote) => quote.status === 'offered') ?? null;
const [prompt, setPrompt] = useState(canonicalPrompt);
const [promptDirty, setPromptDirty] = useState(false);
@@ -183,12 +205,35 @@ export function YouthCreationCard({
));
const busy = localBusy || submitting || uploading;
const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker');
const options = generationOptions ?? UNAVAILABLE_GENERATION_OPTIONS;
const activeOptions = options[medium];
const supportedAspectRatios = activeOptions.supportedAspectRatios;
const supportedDurations = activeOptions.supportedDurationSeconds;
const selectedFirstFrameAssetId = values.video.first_frame_asset_id;
const selectedFirstFrameAsset = selectedFirstFrameAssetId
? workspace.assets.find((asset) => asset.assetId === selectedFirstFrameAssetId) ?? null
: null;
const videoNeedsFirstFrame = medium === 'video' && activeOptions.requiresFirstFrame && !selectedFirstFrameAsset;
const imageReferencesSupported = activeOptions.supportedReferenceRoles.length > 0;
const unsupportedReferences = references.some((reference) => (
!activeOptions.supportedReferenceRoles.includes(reference.role)
));
const canPrepareQuote = generationAvailable
&& activeOptions.available
&& prompt.trim().length > 0
&& Boolean(medium)
&& Boolean(aspectRatio)
&& supportedAspectRatios.includes(aspectRatio)
&& (!videoNeedsFirstFrame || !activeOptions.requiresFirstFrame)
&& !unsupportedReferences
&& unboundReferenceNumbers.length === 0
&& blockers.length === 0;
&& variantCount <= activeOptions.maxOutputCount
&& !(medium === 'video' && !supportedDurations.includes(duration));
const canAddReference = medium === 'video'
? activeOptions.supportedReferenceRoles.includes('first_frame')
&& (!selectedFirstFrameAsset
|| activeOptions.supportedReferenceRoles.includes('inspiration'))
: imageReferencesSupported;
const setPromptAndCursor = (value: string, cursor: number) => {
setPrompt(value);
@@ -202,7 +247,7 @@ export function YouthCreationCard({
const runOperations = async (
operations: DesignUserFieldOperation[],
options: { ensureQuote?: boolean; nextPrompt?: string } = {},
options: { ensureQuote?: boolean; nextPrompt?: string; prepare?: boolean } = {},
) => {
if (busy || operations.length === 0) return null;
const shouldRefreshQuote = options.ensureQuote === true || Boolean(offeredQuote);
@@ -210,10 +255,12 @@ export function YouthCreationCard({
setSaveState('saving');
let saved = false;
try {
const updated = await applyFieldOperations(operations);
const updated = options.prepare
? await prepareGeneration(operations)
: await applyFieldOperations(operations);
saved = true;
if (options.nextPrompt !== undefined) {
setPrompt(options.nextPrompt);
setPrompt(options.prepare ? creatorPromptForWorkspace(updated) : options.nextPrompt);
setPromptDirty(false);
}
if (shouldRefreshQuote) {
@@ -275,6 +322,19 @@ export function YouthCreationCard({
try {
const asset = await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file);
const replacing = replaceIndex;
const replacingFirstFrame = replacing !== null && references[replacing]?.role === 'first_frame';
if (medium === 'video' && (selectedFirstFrameAsset === null || replacingFirstFrame)) {
const firstFrame = createVideoFirstFrameOperations(workspace, asset, prompt);
setUploading(false);
const updated = await runOperations(firstFrame.operations, {
nextPrompt: firstFrame.nextPrompt,
prepare: true,
});
if (!updated) return;
setMentionRange(null);
toast.success('视频开始画面已选好');
return;
}
const nextReferences: ReferenceValue[] = replacing === null
? [...references, {
id: createReferenceId(),
@@ -301,7 +361,11 @@ export function YouthCreationCard({
operations.push({ kind: 'set', path: 'content.concept', value: inserted.value.trim() });
}
setUploading(false);
await runOperations(operations, { nextPrompt: inserted.value.trim() });
const updated = await runOperations(operations, {
nextPrompt: inserted.value.trim(),
prepare: medium === 'video',
});
if (!updated) return;
setMentionRange(null);
toast.success(replacing === null ? '参考图已添加并写入提示词' : '参考图已替换');
} catch {
@@ -325,9 +389,27 @@ export function YouthCreationCard({
? { kind: 'set', path: 'content.concept', value: nextPrompt }
: { kind: 'clear', path: 'content.concept', resolution: 'open' },
];
if (references[index]?.asset_id === values.video.first_frame_asset_id) {
operations.push({ kind: 'clear', path: 'video.first_frame_asset_id' });
}
void runOperations(operations, { nextPrompt });
};
const handleAssetAsFirstFrame = (asset: NonNullable<DesignWorkspace['assets'][number]>) => {
if (busy || medium !== 'video') return;
try {
const firstFrame = createVideoFirstFrameOperations(workspace, asset, prompt);
void runOperations(firstFrame.operations, {
nextPrompt: firstFrame.nextPrompt,
prepare: true,
}).then((updated) => {
if (updated) toast.success('视频开始画面已更新');
});
} catch {
toast.error('这张图片暂时不能用于视频,请检查制作方案');
}
};
const insertExistingReference = (index: number) => {
if (!mentionRange && promptReferencesImage(prompt, index)) {
promptRef.current?.focus();
@@ -342,8 +424,9 @@ export function YouthCreationCard({
if (saveState === 'repricing') return { text: '正在重新核价', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
if (saveState === 'error') return { text: '方案需要重试', icon: <RefreshCw className="h-4 w-4" /> };
if (promptDirty) return { text: '提示词尚未保存', icon: <Sparkles className="h-4 w-4" /> };
if (blockers.length > 0) return { text: '还需要补充一点信息', icon: <Sparkles className="h-4 w-4" /> };
return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: <Check className="h-4 w-4" /> };
}, [offeredQuote, promptDirty, saveState]);
}, [blockers.length, offeredQuote, promptDirty, saveState]);
if (!hasActiveCreationPlan(workspace)) return null;
@@ -371,7 +454,7 @@ export function YouthCreationCard({
<button
type="button"
className="motion-press inline-flex h-8 items-center gap-1.5 rounded-lg bg-surface-subtle px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-tertiary hover:text-brand"
disabled={busy}
disabled={busy || !canAddReference}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setReplaceIndex(null);
@@ -379,7 +462,7 @@ export function YouthCreationCard({
}}
>
<ImagePlus className="h-3.5 w-3.5" />
添加参考图
{medium === 'video' && videoNeedsFirstFrame ? '选择视频开始画面' : '添加参考图'}
</button>
</div>
<div className="relative mt-2">
@@ -451,7 +534,7 @@ export function YouthCreationCard({
}}
>
<Plus className="h-3.5 w-3.5" />
上传新的参考图
{medium === 'video' && videoNeedsFirstFrame ? '选择视频开始画面' : '上传新的参考图'}
</button>
</div>
)}
@@ -464,11 +547,14 @@ export function YouthCreationCard({
{(references.length > 0 || unboundReferenceNumbers.length > 0) && (
<section aria-label="参考图" className="border-t border-border/60 pt-3">
<h3 className="text-xs font-semibold text-foreground">参考图 {references.length > 0 ? references.length : ''}</h3>
<h3 className="text-xs font-semibold text-foreground">
{medium === 'video' ? '视频开始画面和参考图' : '参考图'} {references.length > 0 ? references.length : ''}
</h3>
<div className="mt-2 grid gap-2 lg:grid-cols-2">
{references.map((reference, index) => {
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
const referenced = promptReferencesImage(prompt, index);
const isStartingImage = reference.asset_id === selectedFirstFrameAssetId && medium === 'video';
const referenced = isStartingImage || promptReferencesImage(prompt, index);
return (
<article key={reference.id} className="flex min-h-14 items-center gap-2.5 rounded-xl border border-border/70 bg-background p-2">
<DesignAssetThumbnail asset={asset} className="h-11 w-11 rounded-lg" />
@@ -480,20 +566,33 @@ export function YouthCreationCard({
>
<span className="block truncate text-xs font-semibold text-foreground">{referenceToken(index)}</span>
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
{asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'}
{reference.role === 'first_frame'
? '视频开始画面'
: asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'}
</span>
</button>
<span className={cn(
'shrink-0 rounded-md px-1.5 py-1 text-[10px] font-medium',
referenced ? 'bg-emerald-50 text-emerald-700' : 'bg-surface-subtle text-muted-foreground',
)}>
{referenced ? '已引用' : '未引用'}
{isStartingImage ? '已选用' : referenced ? '已引用' : '未引用'}
</span>
{medium === 'video' && asset && reference.role !== 'first_frame' && (
<button
type="button"
className="shrink-0 rounded-md px-1.5 py-1 text-[10px] font-medium text-brand hover:bg-brand/[0.06] disabled:opacity-40"
disabled={busy}
onClick={() => handleAssetAsFirstFrame(asset)}
>
用作开始画面
</button>
)}
<button
type="button"
aria-label={`替换参考图 ${index + 1}`}
title="替换"
disabled={busy}
disabled={busy || (medium === 'video'
&& !activeOptions.supportedReferenceRoles.includes(reference.role))}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-40"
onClick={() => {
setReplaceIndex(index);
@@ -519,17 +618,24 @@ export function YouthCreationCard({
<button
type="button"
className="flex min-h-14 items-center justify-center gap-2 rounded-xl border border-dashed border-border px-3 text-xs font-medium text-foreground hover:border-brand/30 hover:bg-brand/[0.025]"
disabled={busy}
disabled={busy || !canAddReference}
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
}}
>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
添加参考图
{medium === 'video' && videoNeedsFirstFrame ? '选择视频开始画面' : '添加参考图'}
</button>
</div>
{unsupportedReferences && (
<p className="mt-2 rounded-lg bg-amber-50/80 px-3 py-2 text-xs leading-5 text-amber-900">
{medium === 'video' ? '当前视频制作只使用一张开始画面,请移除其他用途的引用。' : '当前图片制作不支持这些参考用途,请移除不支持的引用。'}
点击图片旁的删除按钮只会移除引用,不会删除图片素材。
</p>
)}
{unboundReferenceNumbers.length > 0 && (
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50/65 px-3 py-2">
<p className="text-xs leading-5 text-amber-900">
@@ -539,7 +645,7 @@ export function YouthCreationCard({
<button
type="button"
className="h-8 rounded-lg bg-background px-2.5 text-xs font-medium text-amber-900 shadow-sm hover:bg-amber-100"
disabled={busy}
disabled={busy || !canAddReference}
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
@@ -555,6 +661,12 @@ export function YouthCreationCard({
</section>
)}
{videoNeedsFirstFrame && (
<p className="rounded-lg bg-amber-50/80 px-3 py-2 text-xs leading-5 text-amber-900">
先选一张图片作为视频开始画面,AI 才能继续整理视频方案。可以上传图片,也可以使用历史作品里的图片。
</p>
)}
<input
ref={uploadInputRef}
type="file"
@@ -577,15 +689,31 @@ export function YouthCreationCard({
className="h-8 w-[82px] rounded-lg px-2 py-0 pr-7 text-xs"
onChange={(event) => {
const next = event.currentTarget.value as 'image' | 'video';
if (!options[next].available) {
toast.error(`${next === 'video' ? '视频' : '图片'}制作暂不可用`);
return;
}
const operations: DesignUserFieldOperation[] = [{ kind: 'set', path: 'intent.media', value: next }];
if (next === 'video' && values.video.total_duration_seconds === null) {
operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: duration });
const durations = options.video.supportedDurationSeconds;
const initialDuration = durations.includes(6) ? 6 : durations[0];
if (initialDuration !== undefined) {
operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: initialDuration });
setDuration(initialDuration);
}
}
changeParameter(operations, () => setMedium(next));
if (busy) return;
setMedium(next);
void runOperations(operations, { prepare: true }).then((updated) => {
if (!updated) {
setMedium(values.intent.media ?? 'image');
setDuration(values.video.total_duration_seconds ?? 6);
}
});
}}
>
<option value="image">图片</option>
<option value="video">视频</option>
<option value="image" disabled={!options.image.available}>图片</option>
<option value="video" disabled={!options.video.available}>视频</option>
</Select>
</label>
@@ -608,7 +736,18 @@ export function YouthCreationCard({
}}
>
<option value="" disabled>请选择</option>
{ASPECT_RATIOS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
{ASPECT_RATIOS.map((item) => (
<option
key={item.value}
value={item.value}
disabled={!supportedAspectRatios.includes(item.value)}
>
{item.label}{supportedAspectRatios.includes(item.value) ? '' : '(暂不可用)'}
</option>
))}
{aspectRatio && !ASPECT_RATIOS.some((item) => item.value === aspectRatio) && (
<option value={aspectRatio} disabled>{aspectRatio}(当前不可用)</option>
)}
</Select>
</label>
@@ -618,7 +757,8 @@ export function YouthCreationCard({
value={duration}
suffix="秒"
minimum={1}
maximum={30}
maximum={Math.max(1, ...supportedDurations)}
allowedValues={supportedDurations}
disabled={busy}
onChange={(next) => changeParameter(
[{ kind: 'set', path: 'video.total_duration_seconds', value: next }],
@@ -631,7 +771,7 @@ export function YouthCreationCard({
label="数量"
value={variantCount}
minimum={1}
maximum={4}
maximum={Math.max(1, activeOptions.maxOutputCount)}
disabled={busy}
onChange={(next) => changeParameter(
[{ kind: 'set', path: 'output.variant_count', value: next }],
@@ -640,6 +780,12 @@ export function YouthCreationCard({
/>
</section>
{!activeOptions.available && (
<p className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-xs leading-5 text-amber-900">
当前暂时不能制作{medium === 'video' ? '视频' : '图片'},可以先继续完善想法。
</p>
)}
{blockers.length > 0 && (
<div className="rounded-xl border border-destructive/15 bg-destructive/[0.035] px-3 py-2 text-xs leading-5 text-destructive">
{blockers.map((blocker) => (
@@ -676,7 +822,8 @@ export function YouthCreationCard({
<Button
type="button"
className="h-10 rounded-xl px-5"
disabled={busy || confirming || promptDirty || unboundReferenceNumbers.length > 0 || !generationAvailable}
disabled={busy || confirming || promptDirty || unboundReferenceNumbers.length > 0
|| !generationAvailable || !activeOptions.available || unsupportedReferences}
onClick={() => {
void confirmGeneration(offeredQuote.quoteId).catch(() => {
toast.error('这次没有开始制作,请稍后再试');

View File

@@ -209,6 +209,7 @@ export function ImageCanvas() {
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
generationOptions={bootstrap?.capabilities.generationOptions}
showPlanHistory={!desktopLayout}
/>
) : (

View File

@@ -0,0 +1,104 @@
import type {
DesignAsset,
DesignSpecificationValues,
DesignUserFieldOperation,
DesignWorkspace,
} from '../../../shared/image-workspace';
import {
insertReferenceToken,
promptReferencesImage,
} from './reference-tokens';
type ReferenceValue = DesignSpecificationValues['references'][number];
function createReferenceId(): string {
const random = globalThis.crypto?.randomUUID?.()
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `reference-${random}`.slice(0, 64);
}
export function creatorPromptForWorkspace(workspace: DesignWorkspace): string {
const values = workspace.form.specification.values;
return values.content.concept?.trim()
|| values.content.narrative?.trim()
|| [
...values.content.subjects.map((subject) => subject.description.trim()),
values.content.action?.trim(),
values.content.environment?.trim(),
values.content.emotional_tone?.trim(),
].filter((value): value is string => Boolean(value)).join(',');
}
/**
* Build the explicit field changes for using an existing image as a video's
* first frame. The asset is only bound to the design; it is never copied or
* deleted, and existing reference rows stay in place so @图片 numbers remain
* stable.
*/
export function createVideoFirstFrameOperations(
workspace: DesignWorkspace,
asset: DesignAsset,
promptOverride?: string,
): { operations: DesignUserFieldOperation[]; nextPrompt: string; referenceIndex: number } {
if (asset.mediaType !== 'image') {
throw new Error('视频开始画面必须使用图片');
}
const values = workspace.form.specification.values;
const references = values.references;
const currentFirstFrameIndex = references.findIndex((reference) => (
reference.role === 'first_frame'
|| reference.asset_id === values.video.first_frame_asset_id
));
const selectedReferenceIndex = references.findIndex((reference) => (
reference.asset_id === asset.assetId
));
let nextReferences: ReferenceValue[];
let referenceIndex: number;
if (selectedReferenceIndex >= 0) {
referenceIndex = selectedReferenceIndex;
nextReferences = references.map((reference, index) => {
if (index === referenceIndex) return { ...reference, role: 'first_frame' };
if (index === currentFirstFrameIndex && index !== referenceIndex) {
return { ...reference, role: 'inspiration' };
}
return reference;
});
} else if (currentFirstFrameIndex >= 0) {
referenceIndex = currentFirstFrameIndex;
nextReferences = references.map((reference, index) => (
index === referenceIndex
? { ...reference, asset_id: asset.assetId, asset_revision: null, role: 'first_frame' }
: reference
));
} else {
referenceIndex = references.length;
nextReferences = [
...references,
{
id: createReferenceId(),
asset_id: asset.assetId,
asset_revision: null,
role: 'first_frame',
preserve: [],
adapt: [],
do_not_copy: [],
reviewed_observations: [],
},
];
}
const prompt = promptOverride ?? creatorPromptForWorkspace(workspace);
const inserted = promptReferencesImage(prompt, referenceIndex)
? { value: prompt }
: insertReferenceToken(prompt, referenceIndex, null);
const operations: DesignUserFieldOperation[] = [
{ kind: 'set', path: 'references', value: nextReferences },
{ kind: 'set', path: 'video.first_frame_asset_id', value: asset.assetId },
];
if (inserted.value.trim() !== prompt.trim()) {
operations.push({ kind: 'set', path: 'content.concept', value: inserted.value.trim() });
}
return { operations, nextPrompt: inserted.value.trim(), referenceIndex };
}

View File

@@ -98,6 +98,7 @@ type ImageWorkspaceState = {
operations: DesignUserFieldOperation[],
clearDraftPaths?: string[],
) => Promise<DesignWorkspace>;
prepareGeneration: (operations?: DesignUserFieldOperation[]) => Promise<DesignWorkspace>;
sendChat: () => Promise<DesignWorkspace>;
resolveDecisionPrompt: (
promptId: string,
@@ -177,6 +178,10 @@ function isQuoteBlocked(error: unknown): boolean {
&& error.code.toLowerCase() === 'design_quote_blocked';
}
function isPrepareGenerationCommand(command: DesignCommandInput): boolean {
return command.kind === 'apply_input' && command.input.kind === 'prepare_generation';
}
function isDefinitiveCommandFailure(error: unknown): error is ImageWorkspaceApiError {
return error instanceof ImageWorkspaceApiError
&& error.commandOutcome === 'definitive_failure';
@@ -562,7 +567,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
pendingOperations: { ...state.pendingOperations, [operation.id]: operation },
assistantActivities,
error: null,
quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers,
quoteBlockers: command.kind === 'request_quote'
|| (command.kind === 'apply_input' && command.input.kind === 'prepare_generation')
? []
: state.quoteBlockers,
};
});
try {
@@ -666,9 +674,15 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
set((state) => {
const pendingOperations = { ...state.pendingOperations };
delete pendingOperations[operation.id];
const preparationFailedWithoutBlockers = isPrepareGenerationCommand(command)
&& state.quoteBlockers.length === 0;
return {
pendingOperations,
error: isCurrentOperationSelection(state, operation) ? null : state.error,
error: isCurrentOperationSelection(state, operation)
? preparationFailedWithoutBlockers
? '方案还没整理好,请稍后重试'
: null
: state.error,
};
});
} else if (isDefinitiveCommandFailure(error)) {
@@ -1149,6 +1163,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
});
},
prepareGeneration: async (operations = []) => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const command: DesignCommandInput = {
kind: 'apply_input',
workspaceId: workspace.workspace.workspaceId,
sessionId: workspace.workspace.sessionId,
expectedDirectionRevision: workspace.form.directionRevision,
clientOperationId: createImageWorkspaceOperationId(),
input: { kind: 'prepare_generation', operations },
};
return executeCommand(command, { label: '整理制作方案' });
},
sendChat: async () => {
const workspace = get().workspace;
const message = get().chatDraft.trim();
@@ -1185,8 +1213,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
},
requestQuote: async () => {
const workspace = get().workspace;
if (!workspace) throw new Error('请先选择一个设计项目');
const preparedWorkspace = await get().prepareGeneration();
const workspace = get().workspace?.workspace.workspaceId === preparedWorkspace.workspace.workspaceId
? get().workspace ?? preparedWorkspace
: preparedWorkspace;
const command: DesignCommandInput = {
kind: 'request_quote',
workspaceId: workspace.workspace.workspaceId,