950 lines
36 KiB
TypeScript
950 lines
36 KiB
TypeScript
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>
|
||
);
|
||
}
|