feat(*): 创作工作台 - 优化全景打光交互、生成模式与图片缓存
This commit is contained in:
@@ -311,9 +311,14 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
if usePlayback {
|
||||
serveETag = etag + ":pb"
|
||||
}
|
||||
// 媒体资源具备不可变资源 ID,允许浏览器在本地磁盘安全长期缓存;
|
||||
// stale-while-revalidate 允许优先使用本地磁盘缓存秒开展现,并向服务端异步重验。
|
||||
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
|
||||
// 资源 ID 内容不可变(上传永远生成新 ID,不会原地覆盖):图片可以放心交给浏览器
|
||||
// 磁盘强缓存 30 天,大画布二次打开零请求直读磁盘缓存。视频/音频涉及转码副本
|
||||
// 就绪与 Range 语义,保持逐次条件请求(304)。
|
||||
if strings.HasPrefix(resource.MimeType, "image/") {
|
||||
c.Header("Cache-Control", "private, max-age=2592000, stale-while-revalidate=86400")
|
||||
} else {
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
}
|
||||
c.Header("ETag", serveETag)
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 画布图片资源缓存链路优化
|
||||
|
||||
消除首屏满屏转圈与"一直在下载":后端图片资源改 30 天磁盘强缓存(`Cache-Control: max-age=2592000`),前端内存防抖 IndexedDB 元数据写入、O(1) 淘汰预检、并发下载 4→16,视口内远程图片直链首帧上屏(走原生磁盘缓存秒开),Blob 缓存后台平滑升级。已编译通过,需浏览器最终确认:
|
||||
|
||||
- [ ] 打开含 50+ 张图片的大画布(如无限画布 1):图片不再全屏长时间转圈,视口内图片在 200ms 内显示(浏览器原生缓存或网络秒级完成)。
|
||||
- [ ] 刷新页面后再次打开同一画布:单张图片耗时 ≤4ms(from disk cache),无网络请求。
|
||||
- [ ] 监视器 Console Network 选项卡:图片资源响应头为 `Cache-Control: private, max-age=2592000`;HTTP 304 不再出现(本地磁盘命中无需 If-None-Match 验证)。
|
||||
- [ ] 超大画布(200+ 图片)冷启动后,并发下载队列在 1~2 轮完成(不再串行 20+ 秒排队)。
|
||||
- [ ] 切换不同画布、打开多个标签页均无 IndexedDB 阻塞卡顿(touchCacheMeta 内存防抖 + evictFor O(1) 预检生效)。
|
||||
- [ ] 视频资源仍保持 `Cache-Control: private, no-cache`(每次播放仍需校验服务端 ETag)。
|
||||
|
||||
## 后端业务域拆包
|
||||
|
||||
- `internal/service` 只保留别名(4 个文件)。组合根和未拆出实现在 `internal/app`。已抽出域:`kernel`、`platform`、`skills`、`prompts`、`auth`、`canvas`、`assets`、`outbound`、`generation`。handler 仍只打 `*service.Service`。
|
||||
@@ -223,8 +234,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
- 图片节点支持基于原图的打光与镜头控制;确认操作只生成明确的编辑请求,不修改源节点内容。
|
||||
- 打光支持光位、俯仰、亮度、光色、轮廓光和预设;镜头控制由声明式参数构建提示词,生成结果保留对应操作元数据。
|
||||
- 打光弹窗确认后只创建空图片节点并连到原图,不立刻发起生成;提示词、模型和尺寸可在新节点面板里改完再生成。
|
||||
- [ ] 登录画布后分别执行打光与镜头控制,确认新节点、连线、模型配置、失败状态和窄屏菜单行为正确。
|
||||
- [ ] 检查明暗主题、键盘焦点、弹窗滚动、复制提示词与自定义模板;确认取消操作不会触发生成。
|
||||
- [ ] 点击「生成打光效果」后应出现空图片节点和连线,节点面板可改提示词/模型/尺寸;确认不会立刻进入 loading 或打上游。
|
||||
|
||||
## 创作页生成类型胶囊切换
|
||||
|
||||
- 创作页 Composer 的生成类型从「图片生成」下拉菜单改为 视频 | 图片 | 文本 三段胶囊切换(`creation-mode-capsule`),选中态使用 `--control-selected-bg/fg` 中性高亮;旧下拉 `creation-mode-picker-menu` / `creation-menu-icon` / `creation-chat-control.is-mode` 样式已删除。
|
||||
- 录音时控制行不换行(`:has(> .voice-recording-inline)` 时 `nowrap`),录音条改为可压缩(`flex: 1 1 236px; min-width: 140px`,波形可缩);≤680px 恢复换行。
|
||||
- [ ] 创作页(含首页空态和会话态)确认胶囊明暗主题选中/悬停层次正确、高度与其他控件协调,切换视频/图片/文本后模型选择器与参数菜单仍跟随模式变化。
|
||||
- [ ] 录音时右侧模型、清晰度、时长、清空等控件保持在同一行,波形被压缩但不遮挡;窄屏(≤680px)录音时仍可换行不溢出。
|
||||
|
||||
## v1.2.8.rc1 候选版本发布
|
||||
|
||||
@@ -1652,3 +1672,11 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme
|
||||
- [ ] 视频节点只连图片和文本节点、提示词不写任何 @ 引用时:text 仍只有输入框原文,连线文本不自动拼入(8f20240d 行为保持)。
|
||||
- [ ] 图片、文本、音频模式生成行为不变;显式 @文本 引用的图片节点重试同样内联文本内容。
|
||||
- [ ] 引用超长文本(超过模型提示词上限)时提交被拦截并提示具体字符数,不出现静默截断或直接丢弃。
|
||||
## 全景图生成弹窗重设计(2026-09-11)
|
||||
|
||||
按设计系统重写 `canvas-panorama-config-modal.tsx`:由 1120px 双栏(左步骤表单 + 右侧栏)改为 680px 单栏表单结构,分区依次为生成方式(AntD Segmented)、参考图/源图缩略图行、场景描述(AntD TextArea)、全景类型(双选卡片)、高级选项(智能比例 Switch + 复制完整提示词);底部固定操作栏含状态提示、取消与主操作按钮,支持 ⌘↵。视觉遵循「默认无边、选中才有 stroke」:未选中项用 `bg-foreground/5` 表面、无描边,选中项用 accent 环线加浅色填充;选图器内选中缩略图为 2px accent 环 + 角标对勾。修复部分缩略图停留在骨架态的问题:缓存图可能在 onLoad 挂载前已完成,新增 ref 挂载时 `complete && naturalWidth` 补检。功能行为(提示词合成、单/多选约束、配置字段、事件)保持不变。前端 `bun run build` 通过,需浏览器验收:
|
||||
|
||||
- [ ] 画布图片节点工具栏打开「全景图」弹窗:明暗主题下各分区层级清晰,仅选中项显示 accent 描边;生成方式 Segmented 切换后参考图标题、描述区、底部状态同步变化。
|
||||
- [ ] 有画布图片(含视频封面)时从画布添加/搜索/多选参考图,图生模式强制单选;缩略图加载后不再停留骨架态,失败图显示「加载失败」。
|
||||
- [ ] 确认生成与图片直出两条路径行为与旧版一致(配置字段、连线、提示词),⌘↵ 快捷提交可用;「复制完整提示词」内容正确。
|
||||
- [ ] 680px 弹窗在窄屏(<768px)无横向溢出,全景类型卡片换为单列;选图器覆盖层可独立关闭,不误关整个弹窗。
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CanvasCameraControlPopover({ cameraControl, onCameraControlChang
|
||||
{!compact ? <span>摄像机</span> : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
|
||||
<AppModal title={null} closable={false} open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
|
||||
<CanvasNodeCameraPanel
|
||||
cameraControl={cameraControl}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canva
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
@@ -650,16 +650,17 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
? content
|
||||
: node.metadata?.previewContent
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
const isRemoteResource = Boolean(resourceId);
|
||||
// 图片内容随资源 ID 不可变且后端允许磁盘强缓存:视口内的远程图片首帧直接上直链,
|
||||
// 走浏览器原生解码与磁盘缓存;Blob 缓存就绪后再平滑替换,避免刷新后满屏转圈。
|
||||
const synchronousUrl = eager && isRemoteResource && node.type === CanvasNodeType.Image ? peekCachedResourceObjectUrl(storageKey) || resourceFileUrl(resourceId) : "";
|
||||
// Inline data URLs are already local, but decoding thousands of them is
|
||||
// still expensive. Images must wait for the same viewport gate as remote
|
||||
// resources; otherwise DOM virtualization does not reduce image work.
|
||||
const isLazyVisual = node.type === CanvasNodeType.Image;
|
||||
const synchronousUrl = isRemoteResource ? peekCachedResourceObjectUrl(storageKey) : "";
|
||||
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
|
||||
// 优先使用内存中的有效 Blob URL;若已进入视口且有服务端的直接图片地址(非 data: base64),
|
||||
// 立即作为初始图片呈现,利用浏览器原生 HTTP 磁盘缓存秒开,消除满屏转圈等待。
|
||||
const initialUrl = synchronousUrl || (eager && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
|
||||
const initialUrl = synchronousUrl || (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
|
||||
const [url, setUrl] = useState(() => initialUrl);
|
||||
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
|
||||
|
||||
@@ -688,7 +689,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
if (!cancelled && cached) setUrl(cached);
|
||||
else if (!cancelled && eager && fallback) setUrl(fallback);
|
||||
}).catch(() => {
|
||||
if (!cancelled && eager) setUrl(fallback);
|
||||
if (!cancelled && eager) setUrl(synchronousUrl || fallback);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,9 @@ const LIGHT_POSITIONS = [
|
||||
{ label: "后方", azimuth: 180, elevation: 0 },
|
||||
];
|
||||
|
||||
const LIGHTING_PREVIEW_COLUMN_WIDTH = 300;
|
||||
const LIGHTING_SPHERE_SIZE = 220;
|
||||
|
||||
const STYLE_PRESETS = [
|
||||
{ id: "overexposed", name: "过曝胶片", color: "#d4b896", image: "/lighting-presets/overexposed.png", prompt: "overexposed film aesthetic, high-key lighting, washed out highlights, soft diffused light, vintage film look" },
|
||||
{ id: "blueBacklight", name: "蓝色逆光", color: "#1a3a5c", image: "/lighting-presets/blue-backlight.png", prompt: "dramatic backlighting, blue rim light, cool color temperature, silhouette with colored edges, ethereal atmosphere" },
|
||||
@@ -207,11 +210,21 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
<div className="flex p-0">
|
||||
{/* Left col: view tabs + light sphere */}
|
||||
<div className="flex flex-col items-center gap-2 border-r px-3 py-3" style={{ width: 185, borderColor: theme.toolbar.border }}>
|
||||
<div className="mb-1 flex w-full gap-1 rounded-[var(--r-md)] p-1" style={{ background: theme.toolbar.itemHover }}>
|
||||
<div className="flex h-14 items-center justify-between border-b px-5" style={{ borderColor: theme.toolbar.border }}>
|
||||
<h2 className="text-[var(--fs-title)] font-semibold leading-none">打光效果</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭打光效果"
|
||||
className="grid size-8 place-items-center rounded-[var(--r-md)] transition-colors hover:bg-black/5 dark:hover:bg-white/10"
|
||||
style={{ color: theme.node.muted }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid items-stretch" style={{ gridTemplateColumns: `${LIGHTING_PREVIEW_COLUMN_WIDTH}px minmax(0, 1fr)` }}>
|
||||
<div className="flex shrink-0 flex-col items-center gap-2 border-r px-3 py-3" style={{ width: LIGHTING_PREVIEW_COLUMN_WIDTH, borderColor: theme.toolbar.border }}>
|
||||
<div className="flex w-full gap-1 rounded-[var(--r-md)] p-1" style={{ background: theme.toolbar.itemHover }}>
|
||||
{(["perspective", "front"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
@@ -234,45 +247,27 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle col: global controls */}
|
||||
<div className="flex flex-col gap-3 border-r px-3 py-3" style={{ width: 280, borderColor: theme.toolbar.border }}>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2.5 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>全局</span>
|
||||
<PanelToggle label="智能模式" checked={options.smartMode} onChange={(checked) => updateOption("smartMode", checked)} theme={theme} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>亮度</span>
|
||||
<div className="min-w-0 flex-1"><Slider min={0} max={100} value={options.brightness} onChange={(value) => updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} /></div>
|
||||
<div className="flex items-center gap-1 rounded-[var(--r-md)] border px-2 py-1 text-[11px]" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
|
||||
<Sun className="size-3" />
|
||||
<span className="w-8 text-right">{options.brightness}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>亮度</span>
|
||||
<div className="min-w-0 flex-1"><Slider min={0} max={100} value={options.brightness} onChange={(value) => updateOption("brightness", value)} tooltip={{ formatter: (value) => `${value}%` }} /></div>
|
||||
<div className="flex h-6 items-center gap-1 rounded-[var(--r-md)] border px-1.5 text-[11px]" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.muted }}>
|
||||
<Sun className="size-3" />
|
||||
<span className="w-7 text-right tabular-nums">{options.brightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>颜色</span>
|
||||
<div className="relative flex items-center">
|
||||
<label
|
||||
className="relative h-6 w-10 cursor-pointer overflow-hidden rounded border"
|
||||
style={{ borderColor: theme.toolbar.border, background: options.lightColor === "#ffffff" ? "linear-gradient(135deg, #ff0000 0%, #ff9900 25%, #ffff00 50%, transparent 50%, transparent 100%), linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%)" : options.lightColor }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="光线颜色"
|
||||
value={options.lightColor}
|
||||
onChange={(event) => updateOption("lightColor", event.target.value)}
|
||||
className="absolute left-0 top-full h-0 w-0 cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
{options.lightColor === "#ffffff" ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-px w-full rotate-45 bg-red-500" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>颜色</span>
|
||||
<LightColorSwatch value={options.lightColor} theme={theme} onChange={(value) => updateOption("lightColor", value)} />
|
||||
</div>
|
||||
<PanelToggle label="轮廓光" checked={options.rimLight} onChange={(checked) => updateOption("rimLight", checked)} theme={theme} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@@ -292,45 +287,39 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>轮廓光</span>
|
||||
<PanelToggle label="轮廓光" checked={options.rimLight} onChange={(checked) => updateOption("rimLight", checked)} theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right col: smart description + presets */}
|
||||
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>智能模式</span>
|
||||
<textarea
|
||||
value={smartDesc}
|
||||
onChange={(event) => setSmartDesc(event.target.value)}
|
||||
placeholder="简单描述你想要实现的打光效果,或者情绪风格"
|
||||
disabled={!options.smartMode}
|
||||
className="h-16 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-xs outline-none transition-colors"
|
||||
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
|
||||
/>
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={options.stylePreset === preset.id}
|
||||
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
|
||||
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundColor: preset.color,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.22),transparent_36%)]" />
|
||||
<span className="absolute bottom-1.5 left-2 right-2 text-[11px] font-medium leading-tight text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]">{preset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-3" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>智能模式</span>
|
||||
<textarea
|
||||
value={smartDesc}
|
||||
onChange={(event) => setSmartDesc(event.target.value)}
|
||||
placeholder="简单描述你想要实现的打光效果,或者情绪风格"
|
||||
disabled={!options.smartMode}
|
||||
className="h-16 w-full resize-none rounded-[var(--r-lg)] border px-3 py-2 text-xs outline-none transition-colors"
|
||||
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
|
||||
/>
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={options.stylePreset === preset.id}
|
||||
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
|
||||
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundColor: preset.color,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.22),transparent_36%)]" />
|
||||
<span className="absolute bottom-1.5 left-2 right-2 text-[11px] font-medium leading-tight text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]">{preset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -345,8 +334,8 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
type="button"
|
||||
whileHover={reducedMotion ? undefined : { y: -1 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
|
||||
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold"
|
||||
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
|
||||
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-3 text-[var(--fs-label)] font-semibold transition-colors"
|
||||
style={{ borderColor: theme.node.activeStroke, color: theme.node.activeStroke, background: "transparent" }}
|
||||
onClick={handleApply}
|
||||
>
|
||||
<Send className="size-3.5" />生成打光效果
|
||||
@@ -368,6 +357,32 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
);
|
||||
}
|
||||
|
||||
function LightColorSwatch({ value, theme, onChange }: { value: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: string) => void }) {
|
||||
const isWhite = value.toLowerCase() === "#ffffff";
|
||||
return (
|
||||
<label
|
||||
className="relative h-6 w-10 shrink-0 cursor-pointer overflow-hidden rounded border"
|
||||
style={{
|
||||
borderColor: theme.toolbar.border,
|
||||
background: isWhite
|
||||
? "linear-gradient(45deg, #cfcfcf 25%, transparent 25%, transparent 75%, #cfcfcf 75%), linear-gradient(45deg, #cfcfcf 25%, #ffffff 25%, #ffffff 75%, #cfcfcf 75%)"
|
||||
: value,
|
||||
backgroundSize: isWhite ? "8px 8px, 8px 8px" : undefined,
|
||||
backgroundPosition: isWhite ? "0 0, 4px 4px" : undefined,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="光线颜色"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
/>
|
||||
{isWhite ? <span className="pointer-events-none absolute inset-[-20%] m-auto h-px w-[140%] rotate-45 bg-red-500" /> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelToggle({ label, checked, onChange, theme }: { label: string; checked: boolean; onChange: (checked: boolean) => void; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -542,8 +557,13 @@ function drawLightSphere(ctx: CanvasRenderingContext2D, width: number, height: n
|
||||
if (previewImageUrl) {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const imageWidth = radius * 0.5;
|
||||
const imageHeight = radius * 0.75;
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
const aspectRatio = sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : 2 / 3;
|
||||
const maxImageWidth = radius * 0.92;
|
||||
const maxImageHeight = radius * 0.96;
|
||||
const imageWidth = Math.min(maxImageWidth, maxImageHeight * aspectRatio);
|
||||
const imageHeight = Math.min(maxImageHeight, maxImageWidth / aspectRatio);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(0,0,0,0.8)";
|
||||
ctx.shadowBlur = 12;
|
||||
@@ -594,7 +614,7 @@ function LightingSphereControl({ azimuth, elevation, onAngleChange, previewImage
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragStartRef = useRef<{ x: number; y: number; azimuth: number; elevation: number } | null>(null);
|
||||
const size = 180;
|
||||
const size = LIGHTING_SPHERE_SIZE;
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
import { Camera, Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
|
||||
@@ -281,8 +281,7 @@ export function CanvasNodeToolbar({
|
||||
const primary = inGroup("primary");
|
||||
const primaryTools = narrow ? primary.slice(0, 1) : primary;
|
||||
const portraitTools = compact ? [] : inGroup("portrait");
|
||||
const viewpointTools = compact ? [] : inGroup("viewpoint");
|
||||
const lightingTools = compact ? [] : inGroup("lighting");
|
||||
const viewpointLightingTools = compact ? [] : [...inGroup("viewpoint"), ...inGroup("lighting")];
|
||||
const panoramaTools = compact ? [] : inGroup("panorama");
|
||||
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("panorama"), ...inGroup("process")] : inGroup("process");
|
||||
const workspaceTools = narrow ? [] : inGroup("workspace");
|
||||
@@ -319,8 +318,7 @@ export function CanvasNodeToolbar({
|
||||
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{panoramaTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{lightingTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{viewpointLightingTools.length ? <NodeDockMenuButton menuId="viewpoint-lighting" label="视角" icon={<Camera className="size-3.5" />} tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, Compass, Copy, Globe, HelpCircle, Image as ImageIcon, Info, Plus, Search, Sparkles, Type, X } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Button, Input, Switch } from "antd";
|
||||
import { Check, Compass, Copy, Globe, Image as ImageIcon, Info, Plus, Search, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -53,7 +54,6 @@ function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: Panoram
|
||||
"除非用户另有说明,保持真实摄影质感、电影级光影和自然空间纵深",
|
||||
].join(", ");
|
||||
|
||||
const projectionLabel = projection === "spherical" ? "720度球形" : "360度环绕";
|
||||
const ratio = projection === "spherical" ? "2:1" : "4:1";
|
||||
|
||||
const fallbackText = `生成一张可用于全景查看器的完整沉浸式全景图,画面必须是比例${ratio}的等距柱状投影结构,宽度是高度的${projection === "spherical" ? "2倍" : "4倍"},结合导入图片的主体、材质、色彩和风格,并根据文字描述补全四周环境、天空或天花板、地面或地板,让左右边缘自然衔接。${common}`;
|
||||
@@ -68,6 +68,7 @@ function buildPanoramaPrompt(sourceMode: PanoramaSourceMode, projection: Panoram
|
||||
const PanoramaSetupForm = memo(
|
||||
({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onCopyPrompt,
|
||||
previewImageUrl,
|
||||
initialProjection = "spherical",
|
||||
@@ -76,6 +77,7 @@ const PanoramaSetupForm = memo(
|
||||
canvasNodes,
|
||||
}: {
|
||||
onSubmit: (composedPrompt: string, config: PanoramaGenerateConfig) => void;
|
||||
onCancel: () => void;
|
||||
onCopyPrompt?: (prompt: string) => void;
|
||||
previewImageUrl?: string | null;
|
||||
initialProjection?: PanoramaProjection;
|
||||
@@ -87,11 +89,12 @@ const PanoramaSetupForm = memo(
|
||||
const [sourceMode, setSourceMode] = useState<PanoramaSourceMode>(normalizeInitialSourceMode(initialSourceMode, Boolean(previewImageUrl)));
|
||||
const [smartBase, setSmartBase] = useState(initialSmartBase);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [showSmartHelp, setShowSmartHelp] = useState(false);
|
||||
const [assetQuery, setAssetQuery] = useState("");
|
||||
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [draftSelectedIds, setDraftSelectedIds] = useState<string[]>([]);
|
||||
const [loadedImages, setLoadedImages] = useState<Record<string, boolean>>({});
|
||||
const [failedImages, setFailedImages] = useState<Record<string, boolean>>({});
|
||||
|
||||
const canvasReferences = useMemo<PanoramaReferenceImage[]>(() => {
|
||||
const list: PanoramaReferenceImage[] = [];
|
||||
@@ -158,6 +161,8 @@ const PanoramaSetupForm = memo(
|
||||
const effectivePrompt = sourceMode === "image" ? prompt.trim() : prompt.trim() || defaultAiPrompt;
|
||||
const composed = sourceMode === "image" ? effectivePrompt : buildPanoramaPrompt(sourceMode, projection, effectivePrompt);
|
||||
const directReference = selectedReferences[0] ?? null;
|
||||
const outputRatio = projection === "spherical" ? "2:1" : "4:1";
|
||||
const canSubmit = sourceMode === "ai" || Boolean(directReference);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (sourceMode === "image" && !directReference) return;
|
||||
@@ -170,6 +175,32 @@ const PanoramaSetupForm = memo(
|
||||
});
|
||||
}, [composed, directReference, onSubmit, projection, selectedReferences, smartBase, sourceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleSubmit]);
|
||||
|
||||
const markImageLoaded = useCallback((id: string) => {
|
||||
setLoadedImages((current) => (current[id] ? current : { ...current, [id]: true }));
|
||||
}, []);
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImages((current) => (current[id] ? current : { ...current, [id]: true }));
|
||||
}, []);
|
||||
|
||||
const trackImageReady = useCallback(
|
||||
(id: string) => (element: HTMLImageElement | null) => {
|
||||
if (element?.complete && element.naturalWidth > 0) markImageLoaded(id);
|
||||
},
|
||||
[markImageLoaded],
|
||||
);
|
||||
|
||||
const toggleDraftReference = (id: string) => {
|
||||
setDraftSelectedIds((current) => {
|
||||
if (sourceMode === "image") {
|
||||
@@ -189,279 +220,276 @@ const PanoramaSetupForm = memo(
|
||||
setIsAssetPickerOpen(false);
|
||||
};
|
||||
|
||||
const projectionButtons = (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
const renderImageSurface = (image: PanoramaReferenceImage, surfaceClass: string) => {
|
||||
const loaded = loadedImages[image.id];
|
||||
const failed = failedImages[image.id];
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-foreground/5 ${surfaceClass}`}>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.label}
|
||||
draggable={false}
|
||||
ref={trackImageReady(image.id)}
|
||||
onLoad={() => markImageLoaded(image.id)}
|
||||
onError={() => markImageFailed(image.id)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded && !failed ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
{!failed && !loaded && <div className="absolute inset-0 animate-pulse bg-foreground/5" />}
|
||||
{failed && (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<span className="px-2 text-center text-[11px] leading-4 text-foreground/40">加载失败</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const thumbSizeClass = sourceMode === "image" ? "w-[140px]" : "w-[100px]";
|
||||
|
||||
const selectedThumb = (image: PanoramaReferenceImage, index: number) => (
|
||||
<div key={image.id} className={`group relative shrink-0 ${thumbSizeClass}`}>
|
||||
{renderImageSurface(image, "aspect-[4/3] rounded-[var(--r-md)]")}
|
||||
<span className="pointer-events-none absolute left-1.5 top-1.5 inline-flex items-center gap-1 rounded-full bg-black/55 px-1.5 py-0.5 text-[9px] font-medium text-white backdrop-blur-sm">
|
||||
<span className="size-1 rounded-full" style={{ backgroundColor: image.color }} />
|
||||
{sourceMode === "image" ? "源图" : `${index + 1}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjection("spherical")}
|
||||
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "spherical" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
|
||||
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
|
||||
aria-label={`移除${image.label}`}
|
||||
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Globe className="h-3 w-3" /> 720°球体
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjection("cylindrical")}
|
||||
className={`flex items-center justify-center gap-1 rounded-md border px-2 py-1.5 text-xs transition ${projection === "cylindrical" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
|
||||
>
|
||||
<Compass className="h-3 w-3" /> 360°环绕
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const modeButtons = (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceMode("ai")}
|
||||
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode !== "image" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
|
||||
>
|
||||
<Type className="h-3 w-3" /> AI生成全景图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceMode("image")}
|
||||
className={`flex h-10 items-center justify-center gap-1 rounded-md border px-2 text-xs transition ${sourceMode === "image" ? "border-[var(--color-accent)] bg-[var(--color-accent)] text-white" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text)] hover:bg-[var(--color-bg-hover)]"}`}
|
||||
>
|
||||
<ImageIcon className="h-3 w-3" /> 图生全景图
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const smartControl = (
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSmartBase((current) => !current)}
|
||||
className={`inline-flex h-8 items-center gap-1 whitespace-nowrap rounded-md border px-2.5 text-xs transition-colors ${
|
||||
smartBase ? "border-[var(--color-accent)]/60 bg-[var(--color-accent)]/15 text-[var(--color-accent)]" : "border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
|
||||
}`}
|
||||
>
|
||||
{smartBase && <CheckCircle2 className="h-3 w-3" />}
|
||||
智能比例合成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSmartHelp((current) => !current)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{showSmartHelp && (
|
||||
<div className="absolute bottom-full right-0 z-30 mb-2 w-[340px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-3 text-[11px] leading-5 text-[var(--color-text-muted)] shadow-2xl">
|
||||
AI生成全景图会优先提交目标比例;如果供应商没有 2:1 / 4:1,会选择最接近的宽比例生成,再裁切、羽化并归一化。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div className="grid min-h-[560px] grid-cols-[270px_minmax(0,1fr)_330px]">
|
||||
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-subtle)]">
|
||||
<div className="border-b border-[var(--color-border)] px-3 py-2">
|
||||
<div className="text-xs font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "全景源图(单张)" : "全景素材"}</div>
|
||||
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{sourceMode === "image" ? (selectedReferences.length > 0 ? "已选 1 张源图" : "请选择 1 张源图") : `${selectedReferences.length} / ${allReferences.length} 张已加入`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ui-scrollbar nowheel flex-1 overflow-y-auto p-2">
|
||||
<div className="space-y-2">
|
||||
{selectedReferences.map((image, index) => (
|
||||
<div key={image.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-1.5">
|
||||
<div className="aspect-video overflow-hidden rounded bg-[var(--color-bg-subtle)]">
|
||||
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: image.color }} />
|
||||
<div className="min-w-0 flex-1 truncate text-[10px] text-[var(--color-text)]">
|
||||
{sourceMode === "image" ? "源图" : `图${index + 1}`} · {image.label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds((current) => current.filter((id) => id !== image.id))}
|
||||
className="rounded px-1 text-[10px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(sourceMode !== "image" || selectedReferences.length === 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
title={sourceMode === "image" ? "选择全景源图" : "从资产加入图片"}
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
{sourceMode === "image" && selectedReferences.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-2 text-[11px] text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
|
||||
title="替换源图"
|
||||
>
|
||||
替换源图
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-col gap-4 p-6">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={sourceMode === "image" ? "可选备注:使用现成图片作为全景源,不会提交给 AI" : "输入全景场景描述;文字+图片+内置全景提示词会一起提交"}
|
||||
className="nodrag nowheel h-40 w-full resize-none rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-4 py-3 text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
</div>
|
||||
{sourceMode === "ai" ? (
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
|
||||
AI生成全景图会把文字、导入图片和内置全景提示词一起提交,目标是先得到 2:1 全景底图,再进入全景查看器。
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
|
||||
图生全景图只使用第一张源图作为全景底图。图片接近 {projection === "spherical" ? "2:1" : "4:1"} 时直接进入全景查看器;不符合时在本地裁切、羽化并归一化,不会提交给 AI。
|
||||
</div>
|
||||
)}
|
||||
{sourceMode !== "image" && (
|
||||
<details className="text-[11px] text-[var(--color-text-muted)]">
|
||||
<summary className="cursor-pointer hover:text-[var(--color-text)]">查看最终提示词</summary>
|
||||
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded-lg bg-[var(--color-bg-subtle)] p-2 text-[11px] text-[var(--color-text-muted)]">{composed}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="mt-auto flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-4">
|
||||
{sourceMode !== "image" ? (
|
||||
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用当前配置生成全景图。</div>
|
||||
) : (
|
||||
<div className="text-[11px] leading-5 text-[var(--color-text-muted)]">使用现成图片作为全景源,比例处理在本地完成。</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={sourceMode === "image" && !directReference}
|
||||
className="flex h-8 items-center gap-1 whitespace-nowrap rounded-md bg-[var(--color-accent)] px-3 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{sourceMode === "image" ? <ImageIcon className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
|
||||
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="flex flex-col gap-4 border-l border-[var(--color-border)] bg-[var(--color-bg-subtle)] p-4">
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">生成方式</div>
|
||||
{modeButtons}
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">投影方式</div>
|
||||
{projectionButtons}
|
||||
</section>
|
||||
{projection === "cylindrical" && (
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-5 text-amber-700 dark:text-amber-200/85">360° 环绕更适合横向街景/长廊;普通全景建议使用 720° 球体。</div>
|
||||
)}
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">比例兜底</div>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-[11px] leading-5 text-[var(--color-text-muted)]">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span>
|
||||
{sourceMode === "image"
|
||||
? `本地检查源图比例;符合 ${projection === "spherical" ? "2:1" : "4:1"} 时直接使用,不符合时本地裁切、羽化并归一化。`
|
||||
: `会优先请求 ${projection === "spherical" ? "2:1" : "4:1"} 全景比例;如果当前供应商不支持,会自动改用最接近的宽比例,并在生成后走裁切/归一化兜底。`}
|
||||
</span>
|
||||
</div>
|
||||
{sourceMode !== "image" && (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{smartControl}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopyPrompt?.(composed)}
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 text-xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-hover)]"
|
||||
title="复制完整提示词到剪贴板"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制提示词
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
const addTile = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className={`flex shrink-0 flex-col items-center justify-center gap-1.5 rounded-[var(--r-md)] bg-foreground/5 text-[10px] font-medium text-foreground/45 transition-colors hover:bg-foreground/10 hover:text-foreground/65 ${thumbSizeClass} aspect-[4/3]`}
|
||||
>
|
||||
<Plus className="size-4" strokeWidth={1.8} />
|
||||
<span>添加</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
{isAssetPickerOpen && (
|
||||
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/35 backdrop-blur-sm">
|
||||
<div className="w-[560px] max-w-[calc(100%-32px)] rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">{sourceMode === "image" ? "选择要转换的全景源图" : "选择全景参考图"}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">{sourceMode === "image" ? "图生全景图只使用一张现成图片作为全景源,不会提交给 AI" : "AI生成全景图支持多选,可作为风格 / 主体 / 元素参考"}</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex shrink-0 items-center gap-2.5 border-b border-border px-5 py-3">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] bg-foreground/5 text-foreground/70">
|
||||
<Globe className="size-3.5" strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[13px] font-semibold leading-tight">全景图生成</h2>
|
||||
<p className="text-[10px] text-foreground/45">AI 补全环境或直接转换</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="ui-scrollbar nowheel min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
<div className="space-y-3">
|
||||
{/* 生成方式 */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-foreground">生成方式</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={sourceMode === "ai"}
|
||||
onClick={() => setSourceMode("ai")}
|
||||
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
|
||||
sourceMode === "ai"
|
||||
? "bg-accent text-white"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold">AI 生成</div>
|
||||
<div className="text-[9px] leading-tight opacity-75">补全环境</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={sourceMode === "image"}
|
||||
onClick={() => setSourceMode("image")}
|
||||
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
|
||||
sourceMode === "image"
|
||||
? "bg-accent text-white"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold">图片直出</div>
|
||||
<div className="text-[9px] leading-tight opacity-75">直接转换</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => setIsAssetPickerOpen(false)} className="rounded-md p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]">
|
||||
<X className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* 参考图 / 源图 */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold text-foreground">{sourceMode === "image" ? "全景源图" : "参考图"}</div>
|
||||
<Button size="small" type="text" icon={<Plus className="size-3.5" />} onClick={openAssetPicker} className="!h-6 !px-2 !text-[10px]">
|
||||
{sourceMode === "image" && selectedReferences.length > 0 ? "替换" : "添加"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedReferences.map((image, index) => selectedThumb(image, index))}
|
||||
{sourceMode !== "image" && addTile}
|
||||
{sourceMode === "image" && selectedReferences.length === 0 && addTile}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景描述 */}
|
||||
{sourceMode === "ai" && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-foreground">场景描述</div>
|
||||
<Input.TextArea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="例如:黄昏的海边木屋,能看到天空、地面和四周环境"
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全景类型 */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-foreground">全景类型</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "spherical" as const, icon: Globe, title: "球形全景", desc: "720° 沉浸式", ratio: "2:1" },
|
||||
{ value: "cylindrical" as const, icon: Compass, title: "环绕全景", desc: "360° 街景", ratio: "4:1" },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const selected = projection === option.value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setProjection(option.value)}
|
||||
className={`flex items-center gap-2 rounded-[var(--r-md)] px-2.5 py-2 text-left transition-all ${
|
||||
selected
|
||||
? "bg-accent text-white"
|
||||
: "bg-foreground/5 text-foreground hover:bg-foreground/10"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold">{option.title}</span>
|
||||
<span className={`rounded-sm px-1 py-px text-[9px] font-medium ${selected ? "bg-white/20" : "bg-foreground/8 text-foreground/55"}`}>{option.ratio}</span>
|
||||
</div>
|
||||
<div className={`text-[9px] leading-tight ${selected ? "opacity-80" : "text-foreground/50"}`}>{option.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-foreground">高级选项</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3 rounded-[var(--r-md)] bg-foreground/5 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-foreground">智能比例</div>
|
||||
<div className="text-[9px] leading-tight text-foreground/50">模型不支持时自动裁切</div>
|
||||
</div>
|
||||
<Switch size="small" checked={smartBase} onChange={setSmartBase} />
|
||||
</div>
|
||||
{sourceMode === "ai" && (
|
||||
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => onCopyPrompt?.(composed)} className="!h-6 !px-2 !text-[10px]">
|
||||
复制完整提示词
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border px-5 py-2.5">
|
||||
<span className={`truncate text-[10px] ${canSubmit ? "text-foreground/45" : "text-amber-500"}`}>
|
||||
{sourceMode === "image" ? (directReference ? "已选源图" : "请选择源图") : selectedReferences.length > 0 ? `${selectedReferences.length} 张参考图` : "无参考图"}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="small" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" icon={sourceMode === "image" ? <ImageIcon className="size-3.5" /> : <Sparkles className="size-3.5" />} disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{sourceMode === "image" ? "创建全景图" : "生成全景图"}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* 选图面板 */}
|
||||
{isAssetPickerOpen && (
|
||||
<div className="absolute inset-0 z-[260] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-full w-[500px] max-w-full flex-col overflow-hidden rounded-[var(--r-lg)] border border-border bg-background shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground">{sourceMode === "image" ? "选择源图" : "选择参考图"}</div>
|
||||
<div className="mt-0.5 text-[11px] text-foreground/45">
|
||||
{sourceMode === "image" ? "只选一张作为全景源" : `已选 ${draftSelectedIds.length} 张`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAssetPickerOpen(false)}
|
||||
aria-label="关闭"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-[var(--r-md)] text-foreground/45 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b border-[var(--color-border)] px-4 py-2">
|
||||
<label className="flex h-8 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2">
|
||||
<Search className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
value={assetQuery}
|
||||
onChange={(event) => setAssetQuery(event.target.value)}
|
||||
placeholder="搜索图片名称"
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]/70"
|
||||
/>
|
||||
</label>
|
||||
<div className="border-b border-border px-5 py-2">
|
||||
<Input size="small" allowClear value={assetQuery} onChange={(event) => setAssetQuery(event.target.value)} placeholder="搜索图片名称" prefix={<Search className="size-3.5 text-foreground/35" />} />
|
||||
</div>
|
||||
<div className="ui-scrollbar max-h-[380px] overflow-y-auto p-4">
|
||||
<div className="ui-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{filteredReferences.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-4 py-10 text-center text-xs text-[var(--color-text-muted)]">没有匹配的图片资产</div>
|
||||
<div className="rounded-[var(--r-md)] bg-foreground/5 px-4 py-8 text-center text-[11px] text-foreground/45">没有匹配的图片</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{filteredReferences.map((image) => {
|
||||
const selected = draftSelectedIds.includes(image.id);
|
||||
const failed = failedImages[image.id];
|
||||
const loaded = loadedImages[image.id];
|
||||
return (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
onClick={() => toggleDraftReference(image.id)}
|
||||
className={`relative overflow-hidden rounded-lg border bg-[var(--color-bg)] text-left transition-colors ${
|
||||
selected ? "border-[var(--color-accent)]/80 ring-1 ring-[var(--color-accent)]/50" : "border-[var(--color-border)] hover:border-[var(--color-text-muted)]"
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
className={`group relative overflow-hidden rounded-[var(--r-md)] transition-shadow ${selected ? "ring-2 ring-accent" : ""}`}
|
||||
>
|
||||
<div className="aspect-square bg-[var(--color-bg-subtle)]">
|
||||
<img src={image.url} alt={image.label} className="h-full w-full object-cover" draggable={false} />
|
||||
</div>
|
||||
<div className="truncate px-1.5 py-1 text-[10px] text-[var(--color-text)]">{image.label}</div>
|
||||
{selected && <span className="absolute right-1 top-1 rounded bg-[var(--color-accent)] px-1 py-0.5 text-[9px] text-white">已选</span>}
|
||||
{renderImageSurface(image, "aspect-square w-full")}
|
||||
{selected && (
|
||||
<span className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-accent text-white">
|
||||
<Check className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--color-border)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAssetPickerOpen(false)}
|
||||
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-xs text-[var(--color-text-muted)] hover:bg-[var(--color-bg-hover)]"
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<Button size="small" onClick={() => setIsAssetPickerOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmAssetPicker}
|
||||
disabled={sourceMode === "image" && draftSelectedIds.length === 0}
|
||||
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs text-white hover:bg-[var(--color-accent)]/90 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{sourceMode === "image" ? (draftSelectedIds.length > 0 ? "使用此图" : "请选择一张源图") : `加入 ${draftSelectedIds.length} 张`}
|
||||
</button>
|
||||
</Button>
|
||||
<Button size="small" type="primary" disabled={sourceMode === "image" && draftSelectedIds.length === 0} onClick={confirmAssetPicker}>
|
||||
{sourceMode === "image" ? "使用此图" : `加入 ${draftSelectedIds.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -475,11 +503,12 @@ PanoramaSetupForm.displayName = "PanoramaSetupForm";
|
||||
|
||||
export function CanvasPanoramaConfigModal({ open, onCancel, onConfirm, onCopyPrompt, previewImageUrl, initialProjection, initialSourceMode, initialSmartBase, nodes }: CanvasPanoramaConfigModalProps) {
|
||||
return (
|
||||
<AppModal title="全景图生成" open={open} centered footer={null} width={980} flush onCancel={onCancel}>
|
||||
<div className="flex flex-col">
|
||||
<div className="relative">
|
||||
<AppModal open={open} centered footer={null} width={680} flush onCancel={onCancel}>
|
||||
<div className="flex min-h-0 flex-col overflow-hidden" style={{ maxHeight: "min(640px, calc(100vh - 100px))" }}>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<PanoramaSetupForm
|
||||
onSubmit={onConfirm}
|
||||
onCancel={onCancel}
|
||||
onCopyPrompt={onCopyPrompt}
|
||||
previewImageUrl={previewImageUrl}
|
||||
initialProjection={initialProjection}
|
||||
|
||||
@@ -11,28 +11,7 @@ import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResource
|
||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type CanvasWorkspaceMode, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
|
||||
const CANVAS_WORKSPACE_MODE_STORAGE_KEY = "canvas-workspace-mode-v1";
|
||||
|
||||
export function readCanvasWorkspaceMode(): CanvasWorkspaceMode {
|
||||
if (typeof window === "undefined") return "professional";
|
||||
try {
|
||||
return scopedLocalStorage.getItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY) === "simple" ? "simple" : "professional";
|
||||
} catch (error) {
|
||||
console.warn("读取画布工作模式失败,已使用专业模式", error);
|
||||
return "professional";
|
||||
}
|
||||
}
|
||||
|
||||
export function persistCanvasWorkspaceMode(mode: CanvasWorkspaceMode) {
|
||||
try {
|
||||
scopedLocalStorage.setItem(CANVAS_WORKSPACE_MODE_STORAGE_KEY, mode);
|
||||
} catch (error) {
|
||||
console.warn("保存画布工作模式失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
|
||||
|
||||
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const builtinSpec = type in NODE_DEFAULT_SIZE ? getNodeSpec(type as CanvasNodeType) : undefined;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Bot, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
|
||||
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
import type { CanvasMediaPerformanceMode } from "@/types/canvas";
|
||||
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
|
||||
|
||||
type CanvasTopBarProps = {
|
||||
@@ -282,82 +280,6 @@ export function CanvasTopBar({
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const simple = mode === "simple";
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsidePress = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePress);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const selectMode = (nextMode: CanvasWorkspaceMode) => {
|
||||
if (nextMode !== mode) onChange(nextMode);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={reducedMotion ? undefined : { y: -1 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
|
||||
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 whitespace-nowrap text-[var(--fs-caption)] font-semibold leading-none">{simple ? "简洁模式" : "专业模式"}</span>
|
||||
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} className="grid size-4 place-items-center" style={{ color: theme.node.muted }}>
|
||||
<ChevronDown className="size-3" />
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
|
||||
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
role="listbox"
|
||||
aria-label="选择画布工作模式"
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
|
||||
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
|
||||
>
|
||||
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
|
||||
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
// 顶栏是 --z-toolbar 上的绝对定位浮层,会为子元素建立层叠上下文。行内绝对定位的提示
|
||||
// 无论 z-index 多高都无法越过它,会被顶栏下方 --z-panel-floating 的生成任务面板盖住,
|
||||
// 因此提示必须走 portal 的浮层层级。
|
||||
@@ -369,25 +291,6 @@ function CanvasTopBarTooltip({ label, children }: { label: string; children: Rea
|
||||
);
|
||||
}
|
||||
|
||||
function ModeOption({ active, motionEnabled, icon, title, description, theme, onClick }: { active: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
|
||||
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] [&_svg]:size-3.5" style={{ background: theme.spatial.surface, color: active ? theme.accent.primary : theme.node.muted }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-[var(--fs-body)] font-semibold leading-none">{title}</span><span className="mt-1.5 block text-[var(--fs-caption)] leading-5" style={{ color: theme.node.muted }}>{description}</span></span>
|
||||
<span className="grid size-5 shrink-0 place-items-center" style={{ color: theme.accent.primary, opacity: active ? 1 : 0 }}><Check className="size-3.5" /></span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
|
||||
return (
|
||||
<span className="flex min-w-36 items-center justify-between gap-8">
|
||||
|
||||
@@ -82,7 +82,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
|
||||
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
@@ -90,7 +90,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
|
||||
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { cancelGenerationTask } from "@/services/api/task-center";
|
||||
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
||||
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
||||
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
|
||||
@@ -295,7 +295,7 @@ function InfiniteCanvasPage() {
|
||||
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
|
||||
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
|
||||
const workspaceMode: CanvasWorkspaceMode = "professional";
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
|
||||
@@ -352,10 +352,6 @@ function InfiniteCanvasPage() {
|
||||
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
|
||||
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
persistCanvasWorkspaceMode(workspaceMode);
|
||||
}, [workspaceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
|
||||
}, [mediaPerformanceMode]);
|
||||
@@ -1115,6 +1111,9 @@ function InfiniteCanvasPage() {
|
||||
setArtCritiqueNodeId(node.id);
|
||||
} else if (node.type === CanvasNodeType.MediaConversion) {
|
||||
setDialogNodeId(null);
|
||||
} else if (node.type === CanvasNodeType.Panorama) {
|
||||
// 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。
|
||||
setDialogNodeId(null);
|
||||
} else {
|
||||
// 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。
|
||||
// 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。
|
||||
@@ -1132,7 +1131,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const handleNodeDragEnd = useCallback((nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
|
||||
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.MediaConversion || node.type === CanvasNodeType.Panorama || node.type === PORTRAIT_CLEARANCE_NODE_TYPE || node.type === ART_CRITIQUE_NODE_TYPE) {
|
||||
setDialogNodeId(null);
|
||||
return;
|
||||
}
|
||||
@@ -2323,19 +2322,6 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!focusMode ? (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] z-[var(--z-toolbar)] transition-[right,bottom] duration-300 lg:bottom-[var(--canvas-inset-y)]"
|
||||
style={{ right: assistantMounted ? `calc(var(--canvas-inset-x) + ${assistantWidth}px + var(--space-3))` : "var(--canvas-inset-x)" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CanvasNodeSearchModal
|
||||
open={nodeSearchOpen}
|
||||
nodes={nodes}
|
||||
@@ -2613,16 +2599,17 @@ function InfiniteCanvasPage() {
|
||||
flush
|
||||
open
|
||||
centered
|
||||
title="打光效果"
|
||||
title={null}
|
||||
closable={false}
|
||||
footer={null}
|
||||
width={900}
|
||||
width={720}
|
||||
onCancel={() => setLightingNodeId(null)}
|
||||
>
|
||||
<CanvasNodeLightingPanel
|
||||
dataUrl={lightingNode.metadata.content}
|
||||
onClose={() => setLightingNodeId(null)}
|
||||
onConfirm={(options, prompt) => {
|
||||
void generateLightingNode(lightingNode, options, prompt);
|
||||
generateLightingNode(lightingNode, options, prompt);
|
||||
}}
|
||||
/>
|
||||
</AppModal>
|
||||
@@ -2642,7 +2629,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
|
||||
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && dialogNode.type !== CanvasNodeType.Panorama && !selectionBox && !isCanvasNodeMoving ? (
|
||||
<CanvasNodePanelOverlay
|
||||
node={dialogNode}
|
||||
viewport={viewport}
|
||||
|
||||
@@ -588,7 +588,8 @@ export function useCanvasMediaTools({
|
||||
});
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
// 全景节点是纯查看器,创建后不弹提示词面板。
|
||||
setDialogNodeId(null);
|
||||
setPanoramaConfigNodeId(null);
|
||||
message.success(config.sourceMode === "image" ? "已创建全景查看节点" : "已创建全景生成节点");
|
||||
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
@@ -863,49 +864,35 @@ export function useCanvasMediaTools({
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const generateLightingNode = useCallback(async (node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
|
||||
const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const childId = nanoid();
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const title = buildLightingLabel(options);
|
||||
const source = nodeReferenceImage(node);
|
||||
if (!source) return;
|
||||
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
|
||||
if (!styleExecution) return;
|
||||
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||
setLightingNodeId(null);
|
||||
setRunningNodeId(childId);
|
||||
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageSpec.width, height: imageSpec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
|
||||
setNodes((current) => [...current, {
|
||||
id: childId,
|
||||
type: CanvasNodeType.Image,
|
||||
title,
|
||||
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||
width: imageSpec.width,
|
||||
height: imageSpec.height,
|
||||
metadata: {
|
||||
prompt,
|
||||
status: NODE_STATUS_IDLE,
|
||||
generationMode: "image",
|
||||
...generationMetadata,
|
||||
},
|
||||
}]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
const controller = startGenerationRequest(childId, node.id, childId);
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "lighting", lighting: options, ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("打光生成节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally {
|
||||
finishGenerationRequest(childId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
}, [effectiveConfig, setConnections, setDialogNodeId, setLightingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { Reorder } from "motion/react";
|
||||
import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
||||
@@ -624,16 +624,19 @@ export function CreationComposer(props: ComposerProps) {
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||||
{ mode: "video", icon: <Film />, label: "视频生成" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
|
||||
{ mode: "video", icon: <Film />, label: "视频" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
|
||||
];
|
||||
const current = items.find((item) => item.mode === mode) || items[0];
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
|
||||
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
return <div className="creation-mode-capsule" role="group" aria-label="生成类型">
|
||||
{items.map((item) => (
|
||||
<button key={item.mode} type="button" className="creation-chat-control" aria-pressed={item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const sessionBlobs = new Map<string, Blob>();
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
const scheduled = new Set<string>();
|
||||
const cacheMetaTouchWarnings = new Set<string>();
|
||||
const metaTouchedAt = new Map<string, number>();
|
||||
const downloadQueue: Array<() => void> = [];
|
||||
let activeDownloads = 0;
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
@@ -28,17 +29,9 @@ const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
|
||||
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_CACHE_ENTRIES = 500;
|
||||
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const BUDGET_REFRESH_MS = 5 * 60 * 1000;
|
||||
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
|
||||
const MAX_CONCURRENT_DOWNLOADS = 16;
|
||||
const recentlyTouched = new Set<string>();
|
||||
|
||||
export function peekCachedResourceObjectUrl(storageKey: string): string {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!resourceId) return "";
|
||||
const userScope = getActiveUserScope();
|
||||
if (userScope === "guest") return "";
|
||||
const key = `${userScope}:${resourceId}:file`;
|
||||
return objectUrls.get(key) || "";
|
||||
}
|
||||
|
||||
export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
@@ -46,6 +39,15 @@ export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
return readCachedObjectUrl(target);
|
||||
}
|
||||
|
||||
/** 同步读取本会话内存中的 Blob URL,命中时节点首帧即可上屏,不必等 IndexedDB 异步读取。 */
|
||||
export function peekCachedResourceObjectUrl(storageKey: string) {
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!resourceId) return "";
|
||||
const userScope = getActiveUserScope();
|
||||
if (userScope === "guest") return "";
|
||||
return objectUrls.get(`${userScope}:${resourceId}:file`) || "";
|
||||
}
|
||||
|
||||
export async function cacheResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
if (!target) return "";
|
||||
@@ -169,6 +171,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
|
||||
|
||||
try {
|
||||
await write();
|
||||
invalidateCacheStats();
|
||||
return;
|
||||
} catch (firstError) {
|
||||
// 第一次失败通常意味着浏览器配额不足;激进淘汰后只允许再尝试一次,
|
||||
@@ -176,6 +179,7 @@ async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
|
||||
await evictFor(blob.size, target.key, true);
|
||||
try {
|
||||
await write();
|
||||
invalidateCacheStats();
|
||||
return;
|
||||
} catch (retryError) {
|
||||
const cleanupResults = await Promise.allSettled([blobStore.removeItem(target.key), metaStore.removeItem(target.key)]);
|
||||
@@ -226,11 +230,15 @@ async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null
|
||||
}
|
||||
|
||||
async function touchCacheMeta(target: ResourceCacheMeta) {
|
||||
if (recentlyTouched.has(target.key)) return;
|
||||
recentlyTouched.add(target.key);
|
||||
const now = Date.now();
|
||||
// 同一会话内重复访问不落 IndexedDB:淘汰扫描在写入时才需要元数据,
|
||||
// 内存时间戳足够让高频读取跳过跨进程 I/O。
|
||||
const lastTouchedAt = metaTouchedAt.get(target.key);
|
||||
if (lastTouchedAt && now - lastTouchedAt < TOUCH_INTERVAL_MS) return;
|
||||
metaTouchedAt.set(target.key, now);
|
||||
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
|
||||
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() });
|
||||
if (!current || now - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
|
||||
}
|
||||
|
||||
|
||||
@@ -243,12 +251,20 @@ function touchCacheMetaSafely(target: ResourceCacheMeta) {
|
||||
});
|
||||
}
|
||||
|
||||
// 条目数/字节数与存储配额在内存中跟踪,常规写入只做 O(1) 检查,
|
||||
// 只有接近预算才执行全量 LRU 扫描,避免每张图持久化都 iterate 全部元数据。
|
||||
let cacheStats: { count: number; bytes: number } | null = null;
|
||||
let cachedBudget: { value: number; at: number } | null = null;
|
||||
|
||||
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
|
||||
const entryCount = await metaStore.length().catch(() => 0);
|
||||
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
|
||||
// 只有当条目数接近上限或需要激进淘汰时,才进行全量迭代,避免写入每个文件都跑全表扫描
|
||||
if (!aggressive && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
|
||||
|
||||
// 常规写入只在统计可信或明显未接近阈值时跳过全表扫描;写入会使内存统计失效,
|
||||
// 因此不会用可能过时的字节数做永久放行。
|
||||
if (!aggressive) {
|
||||
if (cacheStats && cacheStats.count < MAX_CACHE_ENTRIES && cacheStats.bytes + incomingBytes <= budget) return;
|
||||
if (!cacheStats && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
|
||||
}
|
||||
const metas: ResourceCacheMeta[] = [];
|
||||
await metaStore.iterate<ResourceCacheMeta, void>((value) => {
|
||||
if (value?.key) metas.push(value);
|
||||
@@ -263,6 +279,11 @@ async function evictFor(incomingBytes: number, protectedKey: string, aggressive
|
||||
total -= Math.max(0, candidate.size || 0);
|
||||
count -= 1;
|
||||
}
|
||||
cacheStats = { count, bytes: total };
|
||||
}
|
||||
|
||||
function invalidateCacheStats() {
|
||||
cacheStats = null;
|
||||
}
|
||||
|
||||
async function removeCacheEntry(meta: ResourceCacheMeta) {
|
||||
@@ -274,10 +295,15 @@ async function removeCacheEntry(meta: ResourceCacheMeta) {
|
||||
}
|
||||
|
||||
async function cacheBudget() {
|
||||
if (!navigator.storage?.estimate) return FALLBACK_CACHE_BYTES;
|
||||
const estimate = await navigator.storage.estimate().catch(() => null);
|
||||
if (!estimate?.quota) return FALLBACK_CACHE_BYTES;
|
||||
return Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
|
||||
// 配额估计是跨进程调用且会话内基本稳定,短暂缓存避免每张图一次 IPC 往返。
|
||||
if (cachedBudget && Date.now() - cachedBudget.at < BUDGET_REFRESH_MS) return cachedBudget.value;
|
||||
let value = FALLBACK_CACHE_BYTES;
|
||||
if (navigator.storage?.estimate) {
|
||||
const estimate = await navigator.storage.estimate().catch(() => null);
|
||||
if (estimate?.quota) value = Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
|
||||
}
|
||||
cachedBudget = { value, at: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectUrl(key: string, blob: Blob) {
|
||||
|
||||
@@ -1694,26 +1694,6 @@
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-trigger {
|
||||
border: 0;
|
||||
box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
|
||||
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-trigger:hover:not(:disabled) {
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-menu {
|
||||
border: 0;
|
||||
box-shadow: 0 22px 56px color-mix(in srgb, var(--cn-spatial-shadow) 80%, transparent), inset 0 1px 0 rgba(255,255,255,.04);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.canvas-mode-switch-option:hover:not([aria-selected="true"]) {
|
||||
background: var(--dock-command-hover) !important;
|
||||
}
|
||||
|
||||
.canvas-topbar-project-cluster {
|
||||
width: max-content;
|
||||
max-width: min(360px, calc(50vw - 88px));
|
||||
@@ -2966,54 +2946,6 @@
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.creation-mode-picker-menu {
|
||||
width: 176px;
|
||||
padding: 6px;
|
||||
color: #17191d;
|
||||
}
|
||||
|
||||
.dark .creation-mode-picker-menu {
|
||||
color: #f2f4f6;
|
||||
}
|
||||
|
||||
.creation-mode-picker-menu button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.creation-mode-picker-menu button > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.creation-menu-icon {
|
||||
display: grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.dark .creation-menu-icon {
|
||||
border-color: #30343b;
|
||||
}
|
||||
|
||||
.creation-menu-icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.creation-settings-panel {
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
padding: 16px;
|
||||
@@ -3747,7 +3679,6 @@
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover,
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker[aria-expanded="true"] { border-color: rgba(255, 255, 255, .12) !important; background: #272b32 !important; transform: translateY(-1px); }
|
||||
.creation-chat-control:active { transform: scale(.98); }
|
||||
.creation-chat-control.is-mode { border-color: rgba(6, 182, 212, .36) !important; background: rgba(5, 80, 98, .24) !important; color: #17c4e2 !important; }
|
||||
.creation-chat-control > svg { width: 15px; height: 15px; flex: 0 0 auto; }
|
||||
.creation-chat-control > svg:last-child { width: 14px; height: 14px; color: var(--creation-muted); transition: transform 160ms var(--motion-ease-out); }
|
||||
.creation-chat-control > svg:last-child.is-open { transform: rotate(180deg); }
|
||||
@@ -7606,24 +7537,12 @@
|
||||
0 14px 34px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.aceternity-mode-switch {
|
||||
top: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .canvas-floating-panel {
|
||||
box-shadow:
|
||||
0 1px 1px rgba(0, 0, 0, 0.28),
|
||||
0 14px 34px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.canvas-mode-switch-trigger {
|
||||
border: 0 !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: 0 14px 34px color-mix(in srgb, var(--cn-spatial-shadow) 72%, transparent), inset 0 1px 0 rgba(255,255,255,.045);
|
||||
}
|
||||
|
||||
.canvas-tool-button,
|
||||
.canvas-node-tool-button {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
@@ -10532,6 +10451,9 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 录音条自身可压缩,避免波形 + 胶囊同时出现时把右侧控件挤到第二行。 */
|
||||
.creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: nowrap; }
|
||||
|
||||
.creation-chat-control,
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
|
||||
display: inline-flex;
|
||||
@@ -10560,11 +10482,6 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.creation-chat-control.is-mode {
|
||||
background: rgba(255, 255, 255, .075) !important;
|
||||
color: var(--creation-text) !important;
|
||||
}
|
||||
|
||||
.creation-chat-control[aria-pressed="true"],
|
||||
.creation-chat-control[aria-pressed="true"]:hover {
|
||||
background: rgba(6, 182, 212, .16) !important;
|
||||
@@ -10581,10 +10498,31 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
|
||||
|
||||
html:not(.dark) .creation-chat-control,
|
||||
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { background: transparent !important; color: var(--creation-muted) !important; }
|
||||
html:not(.dark) .creation-chat-control.is-mode,
|
||||
html:not(.dark) .creation-chat-control:hover,
|
||||
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; }
|
||||
|
||||
.creation-mode-capsule {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .06);
|
||||
padding: 2px;
|
||||
}
|
||||
.creation-mode-capsule .creation-chat-control { height: 24px !important; border-radius: 999px !important; }
|
||||
.creation-mode-capsule .creation-chat-control:hover,
|
||||
.creation-home .creation-mode-capsule .creation-chat-control:hover { background: rgba(255, 255, 255, .09) !important; color: var(--creation-text) !important; }
|
||||
.creation-mode-capsule .creation-chat-control[aria-pressed="true"],
|
||||
.creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover,
|
||||
.creation-home .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
|
||||
.creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
|
||||
html:not(.dark) .creation-mode-capsule { background: rgba(28, 32, 37, .05); }
|
||||
html:not(.dark) .creation-mode-capsule .creation-chat-control:hover { background: rgba(28, 32, 37, .07) !important; }
|
||||
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"],
|
||||
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"]:hover { background: var(--control-selected-bg) !important; color: var(--control-selected-fg) !important; }
|
||||
html:not(.dark) .creation-mode-capsule .creation-chat-control[aria-pressed="true"] > svg { color: var(--control-selected-fg) !important; }
|
||||
|
||||
.creation-chat-control:disabled {
|
||||
opacity: .42 !important;
|
||||
cursor: not-allowed;
|
||||
@@ -10654,14 +10592,6 @@ html:not(.dark) .creation-chat-control[aria-pressed="true"] > svg { color: #0e74
|
||||
.creation-range-wrap input::-moz-range-thumb { width: 6px; height: 14px; border-radius: 4px; }
|
||||
.creation-range-labels { margin-top: 2px; color: var(--creation-faint); font-size: 9px; }
|
||||
|
||||
.creation-mode-picker-menu { width: 158px; padding: 5px; color: #f0f1f2; }
|
||||
.creation-mode-picker-menu button { display: flex; width: 100%; height: 34px; align-items: center; gap: 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; padding: 0 7px; font-size: 11px; text-align: left; }
|
||||
.creation-mode-picker-menu button:hover { background: var(--control-selected-bg); }
|
||||
.creation-mode-picker-menu button.is-selected { background: var(--control-selected-bg); color: var(--control-selected-fg); }
|
||||
.creation-mode-picker-menu button > svg { width: 13px; height: 13px; margin-left: auto; }
|
||||
.creation-menu-icon { display: grid; width: 19px; height: 19px; place-items: center; border: 0; border-radius: 5px; background: rgba(255, 255, 255, .07); }
|
||||
.creation-menu-icon svg { width: 12px; height: 12px; }
|
||||
|
||||
.creation-model-picker-menu { width: min(332px, calc(100vw - 24px)) !important; max-height: min(420px, calc(100vh - 24px)); padding: 7px !important; background: transparent !important; color: #f0f1f2 !important; }
|
||||
.creation-model-picker-heading { display: flex; align-items: center; gap: 6px; padding: 4px 7px 7px; color: #8e949d; font-size: 10px; }
|
||||
.creation-model-picker-heading strong { color: #e6e8eb; font-weight: 500; }
|
||||
@@ -10688,13 +10618,11 @@ html:not(.dark) .creation-control-popover-surface,
|
||||
html:not(.dark) .creation-model-picker-surface { background: rgba(255, 255, 255, .94) !important; box-shadow: 0 16px 38px rgba(28, 32, 37, .12) !important; }
|
||||
html:not(.dark) .creation-parameter-section h3,
|
||||
html:not(.dark) .creation-duration-menu > div:first-child,
|
||||
html:not(.dark) .creation-mode-picker-menu,
|
||||
html:not(.dark) .creation-model-picker-menu { color: #727980 !important; }
|
||||
html:not(.dark) .creation-choice-grid button { color: #737a82; }
|
||||
html:not(.dark) .creation-choice-grid button:hover { background: rgba(28, 32, 37, .06); color: #25292e; }
|
||||
html:not(.dark) .creation-duration-menu strong,
|
||||
html:not(.dark) .creation-model-picker-heading strong { color: #20242a; }
|
||||
html:not(.dark) .creation-mode-picker-menu button:hover,
|
||||
html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:first-child > span:first-child { background: rgba(28, 32, 37, .06) !important; }
|
||||
|
||||
@keyframes creation-composer-arrive {
|
||||
@@ -10707,6 +10635,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
.creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; }
|
||||
.creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; }
|
||||
.creation-chat-controls { max-width: none; }
|
||||
.creation-chat-controls:has(> .voice-recording-inline) { flex-wrap: wrap; }
|
||||
.creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; }
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; }
|
||||
.creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); }
|
||||
@@ -11591,8 +11520,7 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
|
||||
.creation-home .creation-chat-dock { min-height: 48px; padding: 4px 12px 12px 16px; }
|
||||
|
||||
.creation-home .creation-chat-control,
|
||||
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
|
||||
.creation-home .creation-chat-control.is-mode {
|
||||
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
@@ -11609,16 +11537,14 @@ html:not(.dark) .creation-duration-choices button:hover { background: rgba(28, 3
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
|
||||
.creation-home .creation-chat-control.is-mode > svg:first-child { color: var(--creation-text); }
|
||||
html:not(.dark) .creation-home .creation-chat-control:hover,
|
||||
html:not(.dark) .creation-home .creation-chat-control[aria-expanded="true"],
|
||||
html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(28, 32, 37, .06) !important; }
|
||||
|
||||
.creation-home .creation-chat-controls > .voice-recording-inline {
|
||||
width: min(236px, calc(100vw - 120px));
|
||||
max-width: 236px;
|
||||
flex: 0 1 236px;
|
||||
flex: 1 1 236px;
|
||||
min-width: 140px;
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
@@ -11630,6 +11556,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
||||
.creation-home .creation-chat-controls > .voice-recording-inline > svg {
|
||||
flex: 0 1 160px;
|
||||
max-width: 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.creation-chat-intro h1 > .creation-intro-emphasis {
|
||||
@@ -12861,8 +12788,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
/* 旧创作规则位于 utilities 层且使用 !important;必须在同层覆盖。 */
|
||||
@layer utilities {
|
||||
.creation-home .creation-chat-control,
|
||||
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker,
|
||||
.creation-home .creation-chat-control.is-mode {
|
||||
.creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
@@ -12879,8 +12805,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-control.is-mode { color: var(--creation-text) !important; }
|
||||
|
||||
.creation-parameter-menu {
|
||||
width: 304px;
|
||||
padding: 13px 14px 14px;
|
||||
@@ -14590,8 +14514,6 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.skill-install-modal .ant-modal-title { font-size: var(--fs-heading-lg); font-weight: 620; }
|
||||
.skill-install-modal .ant-upload-wrapper .ant-upload-drag { border-color: color-mix(in srgb, var(--foreground) 14%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); }
|
||||
.skill-install-modal .ant-upload-wrapper .ant-upload-drag:hover { border-color: color-mix(in srgb, var(--foreground) 28%, transparent); }
|
||||
.skill-install-mode { padding: 3px; }
|
||||
.skill-install-mode [role="radio"] { min-height: 34px; }
|
||||
.skill-install-modal .skill-install-form { margin-top: 16px; }
|
||||
|
||||
.skill-package-modal .ant-modal-container { border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); border-radius: var(--r-xl); background: var(--popover); box-shadow: var(--elevation-panel); }
|
||||
|
||||
Reference in New Issue
Block a user