Files
makelore/src/pages/ImageCanvas/FineTuneDrawer.tsx
T

950 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
import {
Check,
ChevronRight,
ImageIcon,
Loader2,
Palette,
Plus,
SlidersHorizontal,
Trash2,
Type,
Video,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAsset,
DesignCompilationIssue,
DesignSpecificationValues,
DesignUserFieldOperation,
DesignWorkspace,
} from '../../../shared/image-workspace';
import { YOUTH_DESTINATIONS } from './youth-form-projection';
import { youthCompilationIssueMessage } from './youth-issue-copy';
type FineTuneDrawerProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: DesignWorkspace;
quoteBlockers: DesignCompilationIssue[];
};
type Choice = {
value: string;
label: string;
description?: string;
};
const STYLE_CHOICES: Choice[] = [
{ value: '有趣的卡通', label: '有趣的卡通', description: '夸张、轻松、颜色明快' },
{ value: '手绘故事书', label: '手绘故事书', description: '温暖、有笔触、像绘本' },
{ value: '像电影一样', label: '像电影一样', description: '有故事感和戏剧氛围' },
{ value: '梦幻奇想', label: '梦幻奇想', description: '大胆想象、神奇光影' },
{ value: '清爽简洁', label: '清爽简洁', description: '重点清楚、留白舒服' },
{ value: '像真的一样', label: '像真的一样', description: '自然、细节真实' },
];
const VIDEO_PACING: Choice[] = [
{ value: '慢慢展开', label: '慢慢展开' },
{ value: '自然流畅', label: '自然流畅' },
{ value: '很有活力', label: '很有活力' },
];
function useDesktopSheet(): boolean {
const [desktop, setDesktop] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== 'function') return;
const query = window.matchMedia('(min-width: 1024px)');
const update = () => setDesktop(query.matches);
update();
query.addEventListener?.('change', update);
return () => query.removeEventListener?.('change', update);
}, []);
return desktop;
}
function readPath(values: DesignSpecificationValues, path: string): unknown {
let current: unknown = values;
for (const segment of path.split('.')) {
if (!current || typeof current !== 'object' || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function createItemId(prefix: string): string {
const random = globalThis.crypto?.randomUUID?.()
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${random}`.slice(0, 64);
}
function pendingMutation(): boolean {
return Object.values(useImageWorkspaceStore.getState().pendingOperations)
.some((operation) => operation.status === 'submitting');
}
function runOperations(
operations: DesignUserFieldOperation[],
clearDraftPaths: string[] = [],
): void {
void useImageWorkspaceStore.getState().applyFieldOperations(operations, clearDraftPaths)
.catch(() => {
toast.error('修改没有保存,请再试一次');
});
}
function Section({
icon,
title,
description,
children,
}: {
icon: ReactNode;
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="rounded-2xl border border-border/70 bg-background p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand/[0.08] text-brand">
{icon}
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
</div>
</div>
<div className="mt-4 space-y-5">{children}</div>
</section>
);
}
function ChoiceGrid({
label,
value,
choices,
disabled,
onChoose,
columns = 2,
}: {
label: string;
value: string | null;
choices: readonly Choice[];
disabled: boolean;
onChoose: (value: string) => void;
columns?: 2 | 3;
}) {
return (
<fieldset>
<legend className="mb-2 text-sm font-medium text-foreground">{label}</legend>
<div className={cn('grid gap-2', columns === 3 ? 'sm:grid-cols-3' : 'grid-cols-2')}>
{choices.map((choice) => {
const selected = value === choice.value;
return (
<button
key={choice.value}
type="button"
aria-pressed={selected}
disabled={disabled}
className={cn(
'min-h-11 rounded-xl border px-3 py-2.5 text-left transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2',
selected
? 'border-brand/45 bg-brand/[0.08] text-foreground'
: 'border-border/70 bg-background text-foreground hover:border-brand/30 hover:bg-brand/[0.03]',
)}
onClick={() => onChoose(choice.value)}
>
<span className="flex items-center justify-between gap-2 text-sm font-medium">
{choice.label}
{selected && <Check className="h-4 w-4 shrink-0 text-brand" />}
</span>
{choice.description && (
<span className="mt-1 block text-[11px] leading-4 text-muted-foreground">
{choice.description}
</span>
)}
</button>
);
})}
</div>
</fieldset>
);
}
function TextField({
workspace,
path,
label,
placeholder,
rows = 3,
list = false,
}: {
workspace: DesignWorkspace;
path: string;
label: string;
placeholder: string;
rows?: number;
list?: boolean;
}) {
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const canonical = readPath(workspace.form.specification.values, path);
const dirty = Object.hasOwn(fieldDrafts, path);
const draft = dirty
? String(fieldDrafts[path] ?? '')
: list && Array.isArray(canonical)
? canonical.join('\n')
: typeof canonical === 'string'
? canonical
: '';
const locked = workspace.form.specification.field_decisions[path]?.locked === true;
const submitting = Object.values(pendingOperations)
.some((operation) => operation.status === 'submitting');
const save = () => {
const trimmed = draft.trim();
const value = list
? draft.split('\n').map((item) => item.trim()).filter(Boolean)
: trimmed;
const operation: DesignUserFieldOperation = value.length === 0
? { kind: 'clear', path }
: { kind: 'set', path, value };
runOperations([operation], [path]);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor={`fine-${path}`}>
{label}
</label>
<Textarea
id={`fine-${path}`}
value={draft}
rows={rows}
placeholder={placeholder}
disabled={locked || submitting}
onChange={(event) => setFieldDraft(path, event.currentTarget.value)}
/>
{dirty && (
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
className="min-h-11 rounded-xl"
onClick={() => discardFieldDraft(path)}
>
撤销
</Button>
<Button
type="button"
className="min-h-11 rounded-xl"
disabled={locked || submitting}
onClick={save}
>
保存
</Button>
</div>
)}
</div>
);
}
function ExactTextEditor({ workspace }: { workspace: DesignWorkspace }) {
const path = 'content.exact_content';
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
const canonical = workspace.form.specification.values.content.exact_content;
const dirty = Object.hasOwn(fieldDrafts, path);
const draft = dirty
? String(fieldDrafts[path] ?? '')
: canonical.map((item) => item.text).filter(Boolean).join('\n');
const locked = workspace.form.specification.field_decisions[path]?.locked === true;
const save = () => {
const lines = draft.split('\n').map((line) => line.trim()).filter(Boolean);
const value = lines.map((text, index) => ({
...(canonical[index] ?? {}),
id: canonical[index]?.id ?? createItemId('copy'),
text,
role: canonical[index]?.role ?? (index === 0 ? '画面文字' : null),
language: canonical[index]?.language ?? '简体中文',
hierarchy: canonical[index]?.hierarchy ?? null,
exact: true,
}));
runOperations(
value.length > 0 ? [{ kind: 'set', path, value }] : [{ kind: 'clear', path }],
[path],
);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="fine-exact-text">
画面上要写什么?
</label>
<Textarea
id="fine-exact-text"
value={draft}
rows={3}
placeholder={'每行一句需要准确出现的文字\n例如:周五科学社开放日'}
disabled={locked || pendingMutation()}
onChange={(event) => setFieldDraft(path, event.currentTarget.value)}
/>
<p className="text-[11px] leading-4 text-muted-foreground">这些文字会按原文保留。</p>
{dirty && (
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" className="min-h-11" onClick={() => discardFieldDraft(path)}>
撤销
</Button>
<Button type="button" className="min-h-11" disabled={locked || pendingMutation()} onClick={save}>
保存文字
</Button>
</div>
)}
</div>
);
}
function AssetSelect({
label,
value,
assets,
disabled,
onChange,
}: {
label: string;
value: string | null;
assets: DesignAsset[];
disabled: boolean;
onChange: (assetId: string | null) => void;
}) {
const selectId = useId();
return (
<div className="space-y-2">
<label htmlFor={selectId} className="text-sm font-medium text-foreground">{label}</label>
<Select
id={selectId}
value={value ?? ''}
disabled={disabled}
className="min-h-11"
onChange={(event) => onChange(event.currentTarget.value || null)}
>
<option value="">不使用图片</option>
{assets.filter((asset) => asset.mediaType === 'image').map((asset) => (
<option key={asset.assetId} value={asset.assetId}>
{asset.role === 'uploaded' ? '我上传的图片' : '完成的作品'} · {asset.width}×{asset.height}
</option>
))}
</Select>
</div>
);
}
function ReferenceEditor({ workspace }: { workspace: DesignWorkspace }) {
const path = 'references';
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
const canonical = workspace.form.specification.values.references;
const dirty = Object.hasOwn(fieldDrafts, path);
const references = dirty && Array.isArray(fieldDrafts[path])
? fieldDrafts[path] as typeof canonical
: canonical;
const available = workspace.assets.filter((asset) => (
asset.mediaType === 'image'
&& !references.some((reference) => reference.asset_id === asset.assetId)
));
const locked = workspace.form.specification.field_decisions[path]?.locked === true;
const change = (index: number, key: 'preserve' | 'adapt' | 'do_not_copy', raw: string) => {
const value = raw.split('\n').map((item) => item.trim()).filter(Boolean);
setFieldDraft(path, references.map((reference, itemIndex) => (
itemIndex === index ? { ...reference, [key]: value } : reference
)));
};
const add = (assetId: string) => {
if (!assetId) return;
setFieldDraft(path, [...references, {
id: createItemId('reference'),
asset_id: assetId,
asset_revision: null,
role: 'inspiration',
preserve: [],
adapt: [],
do_not_copy: [],
reviewed_observations: [],
}]);
};
const save = () => runOperations(
references.length > 0 ? [{ kind: 'set', path, value: references }] : [{ kind: 'clear', path }],
[path],
);
return (
<div className="space-y-3">
{references.map((reference, index) => {
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id);
return (
<article key={reference.id} className="rounded-xl bg-surface-subtle/70 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-brand">
<ImageIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">参考图 {index + 1}</p>
<p className="text-[11px] text-muted-foreground">
{asset ? `${asset.width}×${asset.height}` : '图片正在同步'}
</p>
</div>
</div>
<button
type="button"
aria-label={`移除参考图 ${index + 1}`}
disabled={locked || pendingMutation()}
className="flex h-11 w-11 items-center justify-center rounded-xl text-muted-foreground hover:bg-background hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 disabled:opacity-50"
onClick={() => setFieldDraft(path, references.filter((_, itemIndex) => itemIndex !== index))}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="mt-3 grid gap-3">
<Textarea
aria-label={`参考图 ${index + 1} 最想保留什么`}
value={reference.preserve.join('\n')}
rows={2}
placeholder="最想保留什么?例如:圆脸和红围巾"
disabled={locked || pendingMutation()}
onChange={(event) => change(index, 'preserve', event.currentTarget.value)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Textarea
aria-label={`参考图 ${index + 1} 哪里可以改变`}
value={reference.adapt.join('\n')}
rows={2}
placeholder="哪里可以改变?"
disabled={locked || pendingMutation()}
onChange={(event) => change(index, 'adapt', event.currentTarget.value)}
/>
<Textarea
aria-label={`参考图 ${index + 1} 哪些地方不要照着画`}
value={reference.do_not_copy.join('\n')}
rows={2}
placeholder="哪些地方不要照着画?"
disabled={locked || pendingMutation()}
onChange={(event) => change(index, 'do_not_copy', event.currentTarget.value)}
/>
</div>
</div>
</article>
);
})}
{available.length > 0 && (
<div className="flex gap-2">
<Select
aria-label="添加参考图"
className="min-h-11 flex-1"
value=""
disabled={locked || pendingMutation()}
onChange={(event) => add(event.currentTarget.value)}
>
<option value="">选择一张图片作为参考</option>
{available.map((asset) => (
<option key={asset.assetId} value={asset.assetId}>
{asset.role === 'uploaded' ? '我上传的图片' : '完成的作品'} · {asset.width}×{asset.height}
</option>
))}
</Select>
</div>
)}
{workspace.assets.length === 0 && references.length === 0 && (
<p className="rounded-xl border border-dashed border-border px-3 py-4 text-xs leading-5 text-muted-foreground">
先在作品区上传一张图片,再告诉 AI 最想保留和想改变的地方。
</p>
)}
{dirty && (
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" className="min-h-11" onClick={() => discardFieldDraft(path)}>
撤销
</Button>
<Button type="button" className="min-h-11" disabled={locked || pendingMutation()} onClick={save}>
保存参考图
</Button>
</div>
)}
</div>
);
}
function StoryboardEditor({ workspace }: { workspace: DesignWorkspace }) {
const path = 'video.shots';
const fieldDrafts = useImageWorkspaceStore((state) => state.fieldDrafts);
const setFieldDraft = useImageWorkspaceStore((state) => state.setFieldDraft);
const discardFieldDraft = useImageWorkspaceStore((state) => state.discardFieldDraft);
const canonical = workspace.form.specification.values.video.shots;
const dirty = Object.hasOwn(fieldDrafts, path);
const storedDraft = fieldDrafts[path];
const draft = dirty && Array.isArray(storedDraft)
? storedDraft as DesignSpecificationValues['video']['shots']
: canonical;
const locked = workspace.form.specification.field_decisions[path]?.locked === true;
const updateBeat = (index: number, storyBeat: string) => {
setFieldDraft(path, draft.map((shot, shotIndex) => (
shotIndex === index ? { ...shot, story_beat: storyBeat } : shot
)));
};
const addShot = () => {
if (draft.length >= 24) return;
setFieldDraft(path, [...draft, {
id: createItemId('shot'),
start_milliseconds: null,
end_milliseconds: null,
story_beat: '',
framing: null,
camera: null,
subject_motion: null,
environment_motion: null,
focus_behavior: null,
transition: null,
}]);
};
const removeShot = (index: number) => {
setFieldDraft(path, draft.filter((_, shotIndex) => shotIndex !== index));
};
const save = () => {
const shots = draft
.map((shot) => ({ ...shot, story_beat: shot.story_beat?.trim() || null }))
.filter((shot) => Boolean(shot.story_beat));
runOperations(
shots.length > 0 ? [{ kind: 'set', path, value: shots }] : [{ kind: 'clear', path }],
[path],
);
};
return (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
开始、接着、最后发生什么?
</p>
<div className="space-y-2">
{draft.map((shot, index) => (
<div key={shot.id} className="flex items-start gap-2 rounded-xl border border-border/70 bg-background p-2.5">
<div className="min-w-0 flex-1">
<label htmlFor={`fine-storyboard-${shot.id}`} className="text-xs font-medium text-muted-foreground">
画面 {index + 1}
</label>
<Textarea
id={`fine-storyboard-${shot.id}`}
value={shot.story_beat ?? ''}
rows={2}
className="mt-1"
placeholder={index === 0 ? '例如:机器人找到面粉' : '接下来发生什么?'}
disabled={locked || pendingMutation()}
onChange={(event) => updateBeat(index, event.currentTarget.value)}
/>
</div>
<button
type="button"
aria-label={`删除画面 ${index + 1}`}
className="mt-5 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:bg-destructive/[0.08] hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
disabled={locked || pendingMutation()}
onClick={() => removeShot(index)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
))}
</div>
<p className="text-xs leading-5 text-muted-foreground">
清空文字后保存,会删除这个画面。
</p>
<Button
type="button"
variant="outline"
className="min-h-11 w-full rounded-xl"
disabled={locked || pendingMutation() || draft.length >= 24}
onClick={addShot}
>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
{draft.length >= 24 ? '最多 24 个画面' : '添加一个画面'}
</Button>
{dirty && (
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" className="min-h-11" onClick={() => discardFieldDraft(path)}>
撤销
</Button>
<Button type="button" className="min-h-11" disabled={locked || pendingMutation()} onClick={save}>
保存故事
</Button>
</div>
)}
</div>
);
}
export function FineTuneDrawer({
open,
onOpenChange,
workspace,
quoteBlockers,
}: FineTuneDrawerProps) {
const desktop = useDesktopSheet();
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const submitting = Object.values(pendingOperations)
.some((operation) => operation.status === 'submitting');
const values = workspace.form.specification.values;
const media = values.intent.media;
const imageAssets = useMemo(
() => workspace.assets.filter((asset) => asset.mediaType === 'image'),
[workspace.assets],
);
const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker');
const imageEditRelevant = media === 'image' && Boolean(
values.image.source_asset_id
|| ['edit', 'vary', 'composite', 'extend'].includes(values.intent.creation_intent ?? '')
|| values.image.preserve_instructions.length > 0
|| values.image.change_instructions.length > 0
|| values.image.background_behavior
|| typeof values.image.transparency_required === 'boolean',
);
const exactTextRelevant = media === 'image' || values.content.exact_content.length > 0;
const fieldLocked = (path: string) => (
workspace.form.specification.field_decisions[path]?.locked === true
);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side={desktop ? 'right' : 'bottom'}
className={cn(
'flex flex-col overflow-hidden p-0',
desktop
? 'h-full w-[min(520px,100vw)] sm:max-w-[520px]'
: 'max-h-[88vh] rounded-t-3xl',
)}
>
<SheetHeader className="shrink-0 border-b border-border/70 px-5 py-4 text-left">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-brand" />
精细调整
</SheetTitle>
<SheetDescription className="mt-1 leading-5">
只调整这次作品真正用得到的内容,其他细节交给 AI。
</SheetDescription>
</div>
<SheetClose asChild>
<button
type="button"
aria-label="关闭精细调整"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:bg-surface-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
>
<X className="h-4 w-4" />
</button>
</SheetClose>
</div>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-subtle/35 px-4 py-4 sm:px-5">
<div className="space-y-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{blockers.length > 0 && (
<section className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4">
<h3 className="text-sm font-semibold text-amber-950">开始前还需要</h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900">
{blockers.map((blocker) => (
<li key={`${blocker.code}-${blocker.path ?? ''}`}>
{youthCompilationIssueMessage(blocker)}
</li>
))}
</ul>
</section>
)}
<Section
icon={<Palette className="h-4 w-4" />}
title="作品的样子"
description="选择作品类型、使用位置和大致感觉。"
>
<ChoiceGrid
label="想做图片还是视频?"
value={media}
choices={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
disabled={submitting || fieldLocked('intent.media')}
onChoose={(value) => runOperations([{
kind: 'set',
path: 'intent.media',
value,
}])}
/>
<ChoiceGrid
label="准备用在哪里?"
value={values.output.aspect_ratio}
choices={YOUTH_DESTINATIONS.map((destination) => ({
value: destination.aspectRatio,
label: destination.label,
}))}
disabled={submitting
|| fieldLocked('output.aspect_ratio')
|| fieldLocked('output.orientation')}
onChoose={(aspectRatio) => {
const destination = YOUTH_DESTINATIONS.find((item) => item.aspectRatio === aspectRatio);
if (!destination) return;
runOperations([
{ kind: 'set', path: 'output.aspect_ratio', value: destination.aspectRatio },
{ kind: 'set', path: 'output.orientation', value: destination.orientation },
]);
}}
/>
<ChoiceGrid
label="想要什么感觉?"
value={values.visual.art_direction}
choices={STYLE_CHOICES}
columns={3}
disabled={submitting || fieldLocked('visual.art_direction')}
onChoose={(value) => runOperations([
{ kind: 'set', path: 'visual.art_direction', value },
])}
/>
</Section>
{exactTextRelevant && (
<Section
icon={<Type className="h-4 w-4" />}
title="画面上的文字"
description="只有必须准确出现的文字才需要填写。"
>
<ExactTextEditor workspace={workspace} />
</Section>
)}
{(imageAssets.length > 0 || values.references.length > 0) && (
<Section
icon={<ImageIcon className="h-4 w-4" />}
title="参考图片"
description="说清楚最想保留、可以改变和不要照搬的地方。"
>
<ReferenceEditor workspace={workspace} />
{media === 'image' && (
<AssetSelect
label="从哪张图开始修改?"
value={values.image.source_asset_id}
assets={imageAssets}
disabled={submitting || fieldLocked('image.source_asset_id')}
onChange={(assetId) => runOperations([
assetId
? { kind: 'set', path: 'image.source_asset_id', value: assetId }
: { kind: 'clear', path: 'image.source_asset_id' },
])}
/>
)}
{media === 'video' && (
<AssetSelect
label="视频从哪张图开始?"
value={values.video.first_frame_asset_id}
assets={imageAssets}
disabled={submitting || fieldLocked('video.first_frame_asset_id')}
onChange={(assetId) => runOperations([
assetId
? { kind: 'set', path: 'video.first_frame_asset_id', value: assetId }
: { kind: 'clear', path: 'video.first_frame_asset_id' },
])}
/>
)}
</Section>
)}
{imageEditRelevant && (
<Section
icon={<ImageIcon className="h-4 w-4" />}
title="图片调整"
description="需要改原图或处理背景时,再补充这些内容。"
>
<TextField
workspace={workspace}
path="image.preserve_instructions"
label="这些地方别变"
placeholder="每行一项"
list
/>
<TextField
workspace={workspace}
path="image.change_instructions"
label="想改哪里?"
placeholder="每行一项"
list
/>
<TextField
workspace={workspace}
path="image.background_behavior"
label="背景怎么办?"
placeholder="例如:换成夜晚的月球基地"
rows={2}
/>
<ChoiceGrid
label="需要透明背景吗?"
value={values.image.transparency_required === null
? null
: String(values.image.transparency_required)}
choices={[
{ value: 'false', label: '保留背景' },
{ value: 'true', label: '透明背景' },
]}
disabled={submitting || fieldLocked('image.transparency_required')}
onChoose={(value) => runOperations([{
kind: 'set',
path: 'image.transparency_required',
value: value === 'true',
}])}
/>
</Section>
)}
{media === 'video' && (
<Section
icon={<Video className="h-4 w-4" />}
title="视频故事"
description="只决定时长、快慢和故事顺序,镜头细节交给 AI。"
>
<ChoiceGrid
label="视频大约多长?"
value={values.video.total_duration_seconds === null
? null
: String(values.video.total_duration_seconds)}
choices={[
{ value: '5', label: '5 秒' },
{ value: '10', label: '10 秒' },
{ value: '15', label: '15 秒' },
]}
columns={3}
disabled={submitting || fieldLocked('video.total_duration_seconds')}
onChoose={(value) => runOperations([{
kind: 'set',
path: 'video.total_duration_seconds',
value: Number(value),
}])}
/>
<ChoiceGrid
label="故事怎么展开?"
value={values.video.pacing}
choices={VIDEO_PACING}
columns={3}
disabled={submitting || fieldLocked('video.pacing')}
onChoose={(value) => runOperations([{
kind: 'set',
path: 'video.pacing',
value,
}])}
/>
<StoryboardEditor workspace={workspace} />
<TextField
workspace={workspace}
path="video.end_state"
label="最后看到什么?"
placeholder="例如:机器人端出一盘星星饼干"
rows={2}
/>
</Section>
)}
<details className="group rounded-2xl border border-border/70 bg-background">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 rounded-2xl px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand/[0.08] text-brand">
<SlidersHorizontal className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">其他小调整</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
颜色、画面位置和作品数量。
</span>
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div className="space-y-5 border-t border-border/60 px-4 py-4">
<TextField
workspace={workspace}
path="visual.palette"
label="喜欢哪些颜色?"
placeholder="例如:蓝紫色和一点亮黄色;也可以留空让 AI 搭配"
rows={2}
/>
<TextField
workspace={workspace}
path="visual.composition"
label="画面怎么摆?"
placeholder="例如:主角放中间,上方留一点空"
rows={2}
/>
<div className="space-y-2">
<label htmlFor="fine-variant-count" className="text-sm font-medium text-foreground">
做几个版本?
</label>
<Select
id="fine-variant-count"
className="min-h-11"
value={String(values.output.variant_count ?? 1)}
disabled={submitting || fieldLocked('output.variant_count')}
onChange={(event) => runOperations([{
kind: 'set',
path: 'output.variant_count',
value: Number(event.currentTarget.value),
}])}
>
{[1, 2, 3, 4].map((count) => (
<option key={count} value={count}>{count} 个版本</option>
))}
</Select>
</div>
</div>
</details>
<SheetClose asChild>
<Button type="button" variant="outline" className="min-h-11 w-full rounded-xl">
完成调整
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</SheetClose>
{submitting && (
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground" role="status">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在保存你的选择
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}