fix(design): connect video preparation and playable history
This commit is contained in:
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
94
src/pages/ImageCanvas/DesignVideoPreview.tsx
Normal file
94
src/pages/ImageCanvas/DesignVideoPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('这次没有开始制作,请稍后再试');
|
||||
|
||||
@@ -209,6 +209,7 @@ export function ImageCanvas() {
|
||||
workspace={workspace}
|
||||
quoteBlockers={quoteBlockers}
|
||||
generationAvailable={generationAvailable}
|
||||
generationOptions={bootstrap?.capabilities.generationOptions}
|
||||
showPlanHistory={!desktopLayout}
|
||||
/>
|
||||
) : (
|
||||
|
||||
104
src/pages/ImageCanvas/video-first-frame.ts
Normal file
104
src/pages/ImageCanvas/video-first-frame.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user