mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 16:54:29 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 567ead89b1 | |||
| 9754af3bf9 | |||
| 778fd065ec | |||
| f9e4c92ff1 | |||
| 1be6b4fa29 |
@@ -2,6 +2,9 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 新增视频创作台页面。
|
||||
+ [修复] 修复图片节点size参数传递问题。
|
||||
|
||||
## v0.0.8 - 2026-05-24
|
||||
|
||||
+ [新增] 新增用户账号与算力点体系,支持账号密码注册登录、Linux.do OAuth。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 待测试
|
||||
|
||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
|
||||
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
|
||||
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
|
||||
@@ -8,3 +12,5 @@
|
||||
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
|
||||
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
|
||||
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
|
||||
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。
|
||||
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button, ConfigProvider, Popover } from "antd";
|
||||
import { Button, Popover } from "antd";
|
||||
|
||||
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
];
|
||||
const aspectOptions = [
|
||||
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
|
||||
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
|
||||
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
|
||||
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
|
||||
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
|
||||
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
|
||||
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
|
||||
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
|
||||
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
|
||||
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
|
||||
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
|
||||
];
|
||||
|
||||
type CanvasImageSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: keyof AiConfig, value: string) => void;
|
||||
@@ -44,16 +23,6 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
|
||||
const quality = config.quality || "auto";
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const activeSize = config.size || "auto";
|
||||
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const selectAspect = (value: string) => {
|
||||
const option = aspectOptions.find((item) => item.value === value);
|
||||
onConfigChange("size", option?.size || option?.value || "auto");
|
||||
};
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -64,161 +33,13 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
|
||||
color={theme.toolbar.panel}
|
||||
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
|
||||
onOpenChange={onOpenChange}
|
||||
content={
|
||||
<CanvasImageSettingsTheme theme={theme}>
|
||||
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-xl font-semibold">图像设置</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{qualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{aspectOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[86px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-sm transition hover:opacity-80"
|
||||
style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => selectAspect(item.value)}
|
||||
>
|
||||
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{Array.from({ length: 10 }, (_, index) => index + 1).map((value) => (
|
||||
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||
{value} 张
|
||||
</OptionPill>
|
||||
))}
|
||||
<CountInput value={count} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CanvasImageSettingsTheme>
|
||||
}
|
||||
content={<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} />}
|
||||
>
|
||||
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||
<span className="truncate">
|
||||
{qualityLabel(quality)} · {selectedAspect?.label || activeSize} · {count} 张
|
||||
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count} 张
|
||||
</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
|
||||
components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="col-span-2 flex h-10 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) {
|
||||
if (type === "auto") return null;
|
||||
const ratio = width / Math.max(1, height);
|
||||
const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio);
|
||||
const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28;
|
||||
return (
|
||||
<span className="grid h-8 w-10 place-items-center">
|
||||
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||
return (
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function qualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function readSizeDimensions(size: string, fallback: { width: number; height: number }) {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
return {
|
||||
width: match ? Number(match[1]) : fallback.width,
|
||||
height: match ? Number(match[2]) : fallback.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button, Popover } from "antd";
|
||||
|
||||
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasImageSettingsTheme } from "./canvas-image-settings-popover";
|
||||
|
||||
const resolutionOptions = [
|
||||
{ value: "720", label: "720p" },
|
||||
{ value: "480", label: "480p" },
|
||||
];
|
||||
const sizeOptions = [
|
||||
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
|
||||
{ value: "1024x1024", label: "方形", width: 1024, height: 1024 },
|
||||
{ value: "1792x1024", label: "宽屏", width: 1792, height: 1024 },
|
||||
{ value: "1024x1792", label: "长图", width: 1024, height: 1792 },
|
||||
];
|
||||
const secondOptions = [6, 10, 12, 16, 20];
|
||||
|
||||
type CanvasVideoSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
@@ -31,14 +17,6 @@ type CanvasVideoSettingsPopoverProps = {
|
||||
|
||||
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const seconds = config.videoSeconds || "6";
|
||||
const size = normalizeVideoSize(config.size);
|
||||
const dimensions = readSizeDimensions(size);
|
||||
const resolution = normalizeVideoResolution(config.vquality);
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 720));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -49,152 +27,13 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
|
||||
color={theme.toolbar.panel}
|
||||
zIndex={1200}
|
||||
getPopupContainer={() => document.body}
|
||||
content={
|
||||
<CanvasImageSettingsTheme theme={theme}>
|
||||
<div className="w-[360px] space-y-5 rounded-3xl px-1 py-0.5" style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="text-xl font-semibold">视频设置</div>
|
||||
<SettingGroup title="清晰度" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{resolutionOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
<ResolutionInput value={resolution} theme={theme} onChange={(value) => onConfigChange("vquality", value)} />
|
||||
</SettingGroup>
|
||||
<SettingGroup title="尺寸" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<DimensionInput prefix="W" value={dimensions.width} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{sizeOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[82px] cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border bg-transparent text-xs transition hover:opacity-80"
|
||||
style={{ borderColor: size === item.value ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
>
|
||||
<SizePreview width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{secondOptions.map((value) => (
|
||||
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
|
||||
{value}s
|
||||
</OptionPill>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min={6}
|
||||
max={20}
|
||||
className="col-span-2 h-10 rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||
value={seconds}
|
||||
onChange={(event) => onConfigChange("videoSeconds", event.target.value)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
</CanvasImageSettingsTheme>
|
||||
}
|
||||
content={<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} />}
|
||||
>
|
||||
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
|
||||
<span className="truncate">
|
||||
{resolution}p · {sizeLabel(size)} · {seconds}s
|
||||
{videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
|
||||
</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" className="h-10 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80" style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolutionInput({ value, theme, onChange }: { value: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="flex h-10 overflow-hidden rounded-xl border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||
p
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionInput({ prefix, value, theme, onChange }: { prefix: string; value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-10 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text }}>
|
||||
<span className="grid w-10 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SizePreview({ width, height, color }: { width: number; height: number; color: string }) {
|
||||
const longSide = Math.max(width, height);
|
||||
const previewWidth = Math.max(12, Math.round((width / longSide) * 34));
|
||||
const previewHeight = Math.max(12, Math.round((height / longSide) * 34));
|
||||
return <span className="rounded-[4px] border" style={{ width: previewWidth, height: previewHeight, borderColor: color }} />;
|
||||
}
|
||||
|
||||
function normalizeVideoSize(value: string) {
|
||||
if (/^\d+x\d+$/.test(value || "")) return value;
|
||||
return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720";
|
||||
}
|
||||
|
||||
function normalizeVideoResolution(value: string) {
|
||||
if (value === "480p" || value === "low") return "480";
|
||||
if (value === "720p" || value === "auto" || value === "high" || value === "medium") return "720";
|
||||
return value.replace(/p$/i, "") || "720";
|
||||
}
|
||||
|
||||
function readSizeDimensions(size: string) {
|
||||
const match = size.match(/^(\d+)x(\d+)$/);
|
||||
return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 };
|
||||
}
|
||||
|
||||
function sizeLabel(value: string) {
|
||||
return ({ "720x1280": "竖屏", "1280x720": "横屏", "1024x1024": "方形", "1792x1024": "宽屏", "1024x1792": "长图" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, ImagePlus, LoaderCircle, PenLine, Plus, SlidersHorizontal, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, AutoComplete, Button, Checkbox, Drawer, Empty, Image, Input, InputNumber, Modal, Select, Tag, Typography } from "antd";
|
||||
import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Typography } from "antd";
|
||||
import localforage from "localforage";
|
||||
|
||||
import { ImageSettingsPanel } from "@/components/image-settings-panel";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { nanoid } from "nanoid";
|
||||
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||
import { requestEdit, requestGeneration } from "@/services/api/image";
|
||||
@@ -50,13 +53,6 @@ type GenerationLog = {
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
const sizeOptions = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ({ label: value, value }));
|
||||
const qualityOptions = [
|
||||
{ label: "auto", value: "auto" },
|
||||
{ label: "low / 1K", value: "low" },
|
||||
{ label: "medium / 2K", value: "medium" },
|
||||
{ label: "high / 4K", value: "high" },
|
||||
];
|
||||
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
|
||||
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
|
||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
|
||||
@@ -450,8 +446,8 @@ export default function ImagePage() {
|
||||
onPreviewLog={(log) => void previewGenerationLog(log)}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
|
||||
</div>
|
||||
</Drawer>
|
||||
@@ -465,24 +461,17 @@ export default function ImagePage() {
|
||||
}
|
||||
|
||||
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="col-span-2 block min-w-0 sm:col-span-1">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">模型</span>
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">生成次数</span>
|
||||
<InputNumber className="canvas-control-number !w-full" min={1} max={10} value={Number(config.count) || 1} onChange={(value) => updateConfig("count", String(value || 1))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">尺寸</span>
|
||||
<AutoComplete className="canvas-control-select w-full" value={config.size} options={sizeOptions} placeholder="例如 1:1、3:2" onChange={(value) => updateConfig("size", value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">质量</span>
|
||||
<Select className="canvas-control-select w-full" value={config.quality} options={qualityOptions} onChange={(value) => updateConfig("quality", value)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<ImageSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" maxCount={10} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
|
||||
import localforage from "localforage";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, formatDuration } from "@/lib/image-utils";
|
||||
import { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { requestVideoGeneration } from "@/services/api/video";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
type GeneratedVideo = {
|
||||
id: string;
|
||||
url: string;
|
||||
storageKey: string;
|
||||
durationMs: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
type GenerationResult = {
|
||||
id: string;
|
||||
status: "pending" | "success" | "failed";
|
||||
video?: GeneratedVideo;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GenerationLog = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
title: string;
|
||||
time: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
size: string;
|
||||
resolution: string;
|
||||
seconds: string;
|
||||
status: "成功" | "失败";
|
||||
video?: GeneratedVideo;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs";
|
||||
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" });
|
||||
|
||||
export default function VideoPage() {
|
||||
const { message } = App.useApp();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [references, setReferences] = useState<ReferenceImage[]>([]);
|
||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||
const [logs, setLogs] = useState<GenerationLog[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||
const [startedAt, setStartedAt] = useState(0);
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const [selectedLogIds, setSelectedLogIds] = useState<string[]>([]);
|
||||
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
const model = effectiveConfig.videoModel || effectiveConfig.model;
|
||||
const canGenerate = Boolean(prompt.trim());
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || !startedAt) return;
|
||||
const timer = window.setInterval(() => setElapsedMs(performance.now() - startedAt), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, startedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshLogs();
|
||||
}, []);
|
||||
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")).slice(0, 7 - references.length);
|
||||
const nextReferences = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const image = await uploadImage(file);
|
||||
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, 7));
|
||||
};
|
||||
|
||||
const addReferencesFromClipboard = async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const blobs = await Promise.all(items.flatMap((item) => item.types.filter((type) => type.startsWith("image/")).map((type) => item.getType(type))));
|
||||
if (!blobs.length) {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
return;
|
||||
}
|
||||
const nextReferences = await Promise.all(
|
||||
blobs.slice(0, 7 - references.length).map(async (blob, index) => {
|
||||
const image = await uploadImage(blob);
|
||||
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, 7));
|
||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||
} catch {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
setPreviewLog(null);
|
||||
setResults([{ id: nanoid(), status: "pending" }]);
|
||||
const batchStartedAt = performance.now();
|
||||
setStartedAt(batchStartedAt);
|
||||
try {
|
||||
const blob = await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references);
|
||||
const stored = await uploadMediaFile(blob, "video");
|
||||
const nextVideo: GeneratedVideo = {
|
||||
id: nanoid(),
|
||||
url: stored.url,
|
||||
storageKey: stored.storageKey,
|
||||
durationMs: performance.now() - batchStartedAt,
|
||||
width: stored.width || 1280,
|
||||
height: stored.height || 720,
|
||||
bytes: stored.bytes,
|
||||
mimeType: stored.mimeType,
|
||||
};
|
||||
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
|
||||
message.success("视频已生成");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildRequestSnapshot = () => {
|
||||
const text = prompt.trim();
|
||||
if (!text) {
|
||||
message.error("请输入视频提示词");
|
||||
return null;
|
||||
}
|
||||
if (!isAiConfigReady(effectiveConfig, model)) {
|
||||
message.warning("请先完成配置");
|
||||
openConfigDialog(true);
|
||||
return null;
|
||||
}
|
||||
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references] };
|
||||
};
|
||||
|
||||
const retryResult = () => {
|
||||
void generate();
|
||||
};
|
||||
|
||||
const downloadVideo = (video: GeneratedVideo) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = video.url;
|
||||
link.download = "video.mp4";
|
||||
link.click();
|
||||
};
|
||||
|
||||
const saveResultToAssets = (video: GeneratedVideo) => {
|
||||
addAsset({
|
||||
kind: "video",
|
||||
title: "生成视频",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
source: "视频创作台",
|
||||
data: { url: video.url, storageKey: video.storageKey, width: video.width, height: video.height, bytes: video.bytes, mimeType: video.mimeType },
|
||||
metadata: { source: "video-page", prompt },
|
||||
});
|
||||
message.success("已加入我的素材");
|
||||
};
|
||||
|
||||
const insertPickedAsset = async (payload: InsertAssetPayload) => {
|
||||
if (payload.kind === "text") {
|
||||
setPrompt(payload.content);
|
||||
} else if (payload.kind === "image") {
|
||||
const stored = await uploadImage(payload.dataUrl);
|
||||
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, 7));
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
|
||||
const createSession = () => {
|
||||
setPrompt("");
|
||||
setReferences([]);
|
||||
setResults([]);
|
||||
setElapsedMs(0);
|
||||
setStartedAt(0);
|
||||
setSelectedLogIds([]);
|
||||
setPreviewLog(null);
|
||||
};
|
||||
|
||||
const deleteSelectedLogs = () => {
|
||||
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
|
||||
if (previewLog && selectedLogIds.includes(previewLog.id)) {
|
||||
setPreviewLog(null);
|
||||
setResults([]);
|
||||
}
|
||||
setSelectedLogIds([]);
|
||||
setDeleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
const saveLog = (log: GenerationLog) => {
|
||||
void logStore.setItem(log.id, log).then(refreshLogs);
|
||||
};
|
||||
|
||||
const refreshLogs = async () => setLogs(await readStoredLogs());
|
||||
|
||||
const previewGenerationLog = (log: GenerationLog) => {
|
||||
setPreviewLog(log);
|
||||
setLogsOpen(false);
|
||||
setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100">
|
||||
<main className="grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block">
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
|
||||
</aside>
|
||||
|
||||
<section className="grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<div className="thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100">视频创作台</h1>
|
||||
<div className="flex shrink-0 gap-2 lg:hidden">
|
||||
<Button icon={<History className="size-4" />} onClick={() => setLogsOpen(true)}>
|
||||
记录
|
||||
</Button>
|
||||
<Button icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
参数
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">提示词</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<BookOpen className="size-3.5" />} onClick={() => setPromptDialogOpen(true)}>
|
||||
查看提示词库
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => setAssetPickerOpen(true)}>
|
||||
查看我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={7} placeholder="描述镜头运动、主体动作、场景氛围和画面风格" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-base font-semibold">参考图</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<ClipboardPaste className="size-3.5" />} onClick={() => void addReferencesFromClipboard()}>
|
||||
剪切板
|
||||
</Button>
|
||||
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
|
||||
上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
|
||||
{references.map((item) => (
|
||||
<div key={item.id} className="group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800">
|
||||
<img src={item.dataUrl} alt={item.name} className="size-full object-cover" />
|
||||
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考图">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">暂无参考图,最多 7 张</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||
{model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
|
||||
</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
调整
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
|
||||
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<Button type="primary" size="large" block icon={<Sparkles className="size-4" />} loading={running} disabled={!canGenerate || running} onClick={() => void generate()}>
|
||||
开始生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">生成结果</h2>
|
||||
{running ? <Tag className="m-0 px-2 py-1">等待 {formatDuration(elapsedMs)}</Tag> : null}
|
||||
</div>
|
||||
{results.length ? (
|
||||
<div className="grid gap-4">
|
||||
{results.map((result) => (result.status === "success" && result.video ? <ResultVideoCard key={result.id} video={result.video} onDownload={downloadVideo} onSaveAsset={saveResultToAssets} /> : result.status === "failed" ? <FailedVideoCard key={result.id} error={result.error || "生成失败"} onRetry={retryResult} /> : <PendingVideoCard key={result.id} />))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]">
|
||||
<VideoIcon className="mb-4 size-11 text-stone-400" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有生成视频" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void addReferences(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Drawer title="生成记录" placement="bottom" size="large" open={logsOpen} onClose={() => setLogsOpen(false)}>
|
||||
<LogPanel logs={logs} selectedLogIds={selectedLogIds} activeLogId={previewLog?.id} onSelectedLogIdsChange={setSelectedLogIds} onCreateSession={createSession} onDeleteSelected={() => setDeleteConfirmOpen(true)} onPreviewLog={previewGenerationLog} />
|
||||
</Drawer>
|
||||
<Drawer title="参数" placement="bottom" height="82vh" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
|
||||
<div className="grid grid-cols-2 gap-3 pb-4">
|
||||
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
|
||||
</div>
|
||||
</Drawer>
|
||||
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
|
||||
<AssetPickerModal open={assetPickerOpen} defaultTab="my-assets" onInsert={(payload) => void insertPickedAsset(payload)} onClose={() => setAssetPickerOpen(false)} />
|
||||
<Modal title="删除生成记录" open={deleteConfirmOpen} onCancel={() => setDeleteConfirmOpen(false)} onOk={deleteSelectedLogs} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除选中的 {selectedLogIds.length} 条生成记录吗?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { config: AiConfig; model: string; updateConfig: UpdateAiConfig; openConfigDialog: (shouldPromptContinue?: boolean) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="col-span-2 block min-w-0 sm:col-span-1">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">模型</span>
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<VideoSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultVideoCard({ video, onDownload, onSaveAsset }: { video: GeneratedVideo; onDownload: (video: GeneratedVideo) => void; onSaveAsset: (video: GeneratedVideo) => void }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800">
|
||||
<video src={video.url} controls className="aspect-video w-full bg-black object-contain" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800">
|
||||
<div className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400">
|
||||
<span>
|
||||
{video.width}x{video.height}
|
||||
</span>
|
||||
<span>{formatBytes(video.bytes)}</span>
|
||||
<span>{formatDuration(video.durationMs)}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => onSaveAsset(video)}>
|
||||
添加到素材
|
||||
</Button>
|
||||
<Button size="small" icon={<Download className="size-3.5" />} onClick={() => onDownload(video)}>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingVideoCard() {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<LoaderCircle className="size-6 animate-spin" />
|
||||
<span>生成中</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedVideoCard({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20">
|
||||
<div className="flex aspect-video flex-col items-center justify-center gap-3 p-5 text-center">
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-300">生成失败</div>
|
||||
<Typography.Paragraph ellipsis={{ rows: 4 }} className="!mb-0 !text-xs !text-red-500 dark:!text-red-300">
|
||||
{error}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-red-200 p-3 dark:border-red-950">
|
||||
<Button size="small" danger onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogPanel({
|
||||
logs,
|
||||
selectedLogIds,
|
||||
activeLogId,
|
||||
onSelectedLogIdsChange,
|
||||
onCreateSession,
|
||||
onDeleteSelected,
|
||||
onPreviewLog,
|
||||
}: {
|
||||
logs: GenerationLog[];
|
||||
selectedLogIds: string[];
|
||||
activeLogId?: string;
|
||||
onSelectedLogIdsChange: (ids: string[]) => void;
|
||||
onCreateSession: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onPreviewLog: (log: GenerationLog) => void;
|
||||
}) {
|
||||
const allSelected = Boolean(logs.length) && selectedLogIds.length === logs.length;
|
||||
const toggleAll = () => onSelectedLogIdsChange(allSelected ? [] : logs.map((log) => log.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">生成记录</h2>
|
||||
<Tag className="m-0">{logs.length}</Tag>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreateSession}>
|
||||
新建
|
||||
</Button>
|
||||
<Button size="small" icon={<CheckSquare className="size-3.5" />} disabled={!logs.length} onClick={toggleAll}>
|
||||
{allSelected ? "取消" : "全选"}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} disabled={!selectedLogIds.length} onClick={onDeleteSelected}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{logs.map((log) => (
|
||||
<LogCard key={log.id} log={log} selected={selectedLogIds.includes(log.id)} active={activeLogId === log.id} onSelectedChange={(checked) => onSelectedLogIdsChange(checked ? [...selectedLogIds, log.id] : selectedLogIds.filter((id) => id !== log.id))} onClick={() => onPreviewLog(log)} />
|
||||
))}
|
||||
{!logs.length ? <div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700">暂无生成记录</div> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: GenerationLog; selected: boolean; active: boolean; onSelectedChange: (checked: boolean) => void; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className={`block w-full rounded-lg border p-2 text-left transition ${active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900"}`} onClick={onClick}>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2">
|
||||
<Checkbox className="mt-0.5" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelectedChange(event.target.checked)} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-5">{log.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.size}</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.resolution}p</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none">{log.seconds}s</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid justify-items-end gap-2">
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color={log.status === "成功" ? "blue" : "red"}>
|
||||
{log.status}
|
||||
</Tag>
|
||||
<Tag className="m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color="green">
|
||||
{formatDuration(log.durationMs)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function readStoredLogs() {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const logs: GenerationLog[] = [];
|
||||
await logStore.iterate<GenerationLog, void>((value) => {
|
||||
logs.push(value);
|
||||
});
|
||||
return (await Promise.all(logs.map(normalizeLog))).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
|
||||
const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video;
|
||||
return {
|
||||
id: log.id || nanoid(),
|
||||
createdAt: log.createdAt || Date.now(),
|
||||
title: log.title || log.model || "未命名",
|
||||
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model: log.model || "",
|
||||
durationMs: log.durationMs || 0,
|
||||
size: log.size || "",
|
||||
resolution: normalizeResolution(log.resolution || ""),
|
||||
seconds: log.seconds || "",
|
||||
status: log.status || "成功",
|
||||
video,
|
||||
error: log.error,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLog({ prompt, model, config, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
|
||||
return {
|
||||
id: nanoid(),
|
||||
createdAt: Date.now(),
|
||||
title: prompt.slice(0, 12) || "未命名",
|
||||
time: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||||
model,
|
||||
durationMs,
|
||||
size: config.size,
|
||||
resolution: normalizeResolution(config.vquality),
|
||||
seconds: config.videoSeconds,
|
||||
status,
|
||||
video,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVideoConfig(config: AiConfig, model: string): AiConfig {
|
||||
return {
|
||||
...config,
|
||||
model,
|
||||
videoModel: model,
|
||||
size: normalizeVideoSize(config.size),
|
||||
videoSeconds: normalizeVideoSeconds(config.videoSeconds),
|
||||
vquality: normalizeResolution(config.vquality),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
const seconds = Math.floor(Number(value) || 6);
|
||||
return String(Math.max(1, Math.min(20, seconds)));
|
||||
}
|
||||
|
||||
function normalizeVideoSize(value: string) {
|
||||
return normalizeVideoSizeValue(value);
|
||||
}
|
||||
|
||||
function normalizeResolution(value: string) {
|
||||
return normalizeVideoResolutionValue(value);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { ConfigProvider } from "antd";
|
||||
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
];
|
||||
|
||||
const aspectOptions = [
|
||||
{ value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" },
|
||||
{ value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" },
|
||||
{ value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" },
|
||||
{ value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" },
|
||||
{ value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" },
|
||||
{ value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" },
|
||||
{ value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" },
|
||||
{ value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" },
|
||||
{ value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" },
|
||||
{ value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" },
|
||||
{ value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
|
||||
];
|
||||
|
||||
type ImageSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: "quality" | "size" | "count", value: string) => void;
|
||||
theme: CanvasTheme;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
maxCount?: number;
|
||||
quickCount?: number;
|
||||
};
|
||||
|
||||
export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 10 }: ImageSettingsPanelProps) {
|
||||
const quality = config.quality || "auto";
|
||||
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const activeSize = config.size || "auto";
|
||||
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const selectAspect = (value: string) => {
|
||||
const option = aspectOptions.find((item) => item.value === value);
|
||||
onConfigChange("size", option?.size || option?.value || "auto");
|
||||
};
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 1024));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">图像设置</div> : null}
|
||||
<div className="space-y-2.5">
|
||||
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{qualityOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<SettingTitle color={theme.node.muted}>尺寸</SettingTitle>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2.5">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={activeSize === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{aspectOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[72px] cursor-pointer flex-col items-center justify-center gap-1.5 rounded-xl border bg-transparent text-sm transition hover:opacity-80"
|
||||
style={{ borderColor: selectedAspect?.value === item.value ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => selectAspect(item.value)}
|
||||
>
|
||||
<AspectIcon type={item.icon} width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{Array.from({ length: quickCount }, (_, index) => index + 1).map((value) => (
|
||||
<OptionPill key={value} selected={count === value} theme={theme} onClick={() => onConfigChange("count", String(value))}>
|
||||
{value} 张
|
||||
</OptionPill>
|
||||
))}
|
||||
<CountInput value={count} max={maxCount} theme={theme} onChange={(value) => onConfigChange("count", String(value || 1))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; children: ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: { colorBgContainer: theme.toolbar.panel, colorBgElevated: theme.toolbar.panel, colorBorder: theme.node.stroke, colorPrimary: theme.node.activeStroke, colorText: theme.node.text, colorTextLightSolid: theme.node.panel },
|
||||
components: { Button: { defaultBg: theme.toolbar.panel, defaultBorderColor: theme.node.stroke, defaultColor: theme.node.text } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function imageQualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
export function imageSizeLabel(size: string) {
|
||||
return aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: CanvasTheme; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-9 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-9 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CountInput({ value, max, theme, onChange }: { value: number; max: number; theme: CanvasTheme; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="col-span-2 flex h-9 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
style={{ color: theme.node.text, WebkitTextFillColor: theme.node.text }}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(Number(event.target.value) || null)}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) {
|
||||
if (type === "auto") return null;
|
||||
const ratio = width / Math.max(1, height);
|
||||
const boxWidth = ratio >= 1 ? 24 : Math.max(10, 24 * ratio);
|
||||
const boxHeight = ratio >= 1 ? Math.max(10, 24 / ratio) : 24;
|
||||
return (
|
||||
<span className="grid h-7 w-9 place-items-center">
|
||||
<span className="border-2" style={{ width: boxWidth, height: boxHeight, borderColor: color }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTitle({ children, color }: { children: string; color: string }) {
|
||||
return (
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readSizeDimensions(size: string, fallback: { width: number; height: number }) {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
return {
|
||||
width: match ? Number(match[1]) : fallback.width,
|
||||
height: match ? Number(match[2]) : fallback.height,
|
||||
};
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const canvasTheme = canvasThemes[theme];
|
||||
const userName = user?.displayName || user?.username || "用户";
|
||||
const userName = user?.displayName || user?.username || "";
|
||||
const credits = user?.credits ?? 0;
|
||||
const avatarUrl = user?.avatarUrl?.trim();
|
||||
const avatarText = (userName.trim()[0] || "U").toUpperCase();
|
||||
@@ -61,7 +61,7 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
|
||||
<VersionReleaseModal style={versionStyle} />
|
||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||
{variant === "canvas" ? (
|
||||
{variant === "canvas" && user ? (
|
||||
<Tooltip title="当前算力点余额" placement="bottom">
|
||||
<div className="flex h-8 shrink-0 items-center gap-1.5 px-1.5 text-xs font-medium tabular-nums opacity-75 transition hover:opacity-100" style={{ color: canvasTheme.node.text }}>
|
||||
<CreditSymbol className="text-sm leading-none" />
|
||||
@@ -69,21 +69,33 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div ref={accountRef}>
|
||||
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
|
||||
<button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
|
||||
<Avatar
|
||||
size={28}
|
||||
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
|
||||
alt={userName}
|
||||
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-xs font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
style={avatarStyle}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{!user && onOpenShortcuts ? (
|
||||
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
|
||||
<Keyboard className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{!user ? (
|
||||
<Link href="/login" className="px-1.5 text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100" style={iconStyle}>
|
||||
登录
|
||||
</Link>
|
||||
) : null}
|
||||
{user ? (
|
||||
<div ref={accountRef}>
|
||||
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
|
||||
<button type="button" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
|
||||
<Avatar
|
||||
size={28}
|
||||
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
|
||||
alt={userName}
|
||||
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-xs font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
style={avatarStyle}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const resolutionOptions = [
|
||||
{ value: "720", label: "720p" },
|
||||
{ value: "480", label: "480p" },
|
||||
];
|
||||
|
||||
const sizeOptions = [
|
||||
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
|
||||
{ value: "1024x1024", label: "方形", width: 1024, height: 1024 },
|
||||
{ value: "1792x1024", label: "宽屏", width: 1792, height: 1024 },
|
||||
{ value: "1024x1792", label: "长图", width: 1024, height: 1792 },
|
||||
{ value: "auto", label: "auto", width: 0, height: 0 },
|
||||
];
|
||||
|
||||
const secondOptions = [6, 10, 12, 16, 20];
|
||||
|
||||
type VideoSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: "vquality" | "size" | "videoSeconds", value: string) => void;
|
||||
theme: CanvasTheme;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: VideoSettingsPanelProps) {
|
||||
const seconds = config.videoSeconds || "6";
|
||||
const size = normalizeVideoSizeValue(config.size);
|
||||
const dimensions = readSizeDimensions(size);
|
||||
const resolution = normalizeVideoResolutionValue(config.vquality);
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 720));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
<div className={className} style={{ color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{showTitle ? <div className="text-lg font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="清晰度" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{resolutionOptions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
))}
|
||||
<ResolutionInput value={resolution} theme={theme} onChange={(value) => onConfigChange("vquality", value)} />
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="尺寸" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2.5">
|
||||
<DimensionInput prefix="W" value={dimensions.width} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("width", value)} />
|
||||
<span className="text-lg opacity-45">↔</span>
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{sizeOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="flex h-[78px] cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border bg-transparent text-sm transition hover:opacity-80"
|
||||
style={{ borderColor: size === item.value ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
>
|
||||
<SizePreview width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
{item.value === "auto" ? null : (
|
||||
<span className="text-[11px] leading-none opacity-55">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{secondOptions.map((value) => (
|
||||
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
|
||||
{value}s
|
||||
</OptionPill>
|
||||
))}
|
||||
<NumberInput value={seconds} min={1} max={20} theme={theme} onChange={(value) => onConfigChange("videoSeconds", value)} />
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
export function videoResolutionLabel(value: string) {
|
||||
return `${normalizeVideoResolutionValue(value)}p`;
|
||||
}
|
||||
|
||||
export function videoSizeLabel(value: string) {
|
||||
const size = normalizeVideoSizeValue(value);
|
||||
return sizeOptions.find((item) => item.value === size)?.label || size;
|
||||
}
|
||||
|
||||
export function videoSecondsLabel(value: string) {
|
||||
return `${value || "6"}s`;
|
||||
}
|
||||
|
||||
export function normalizeVideoSizeValue(value: string) {
|
||||
if (value === "auto") return "auto";
|
||||
if (/^\d+x\d+$/.test(value || "")) return value;
|
||||
return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720";
|
||||
}
|
||||
|
||||
export function normalizeVideoResolutionValue(value: string) {
|
||||
if (value === "480p" || value === "low") return "480";
|
||||
if (value === "720p" || value === "auto" || value === "high" || value === "medium") return "720";
|
||||
return value.replace(/p$/i, "") || "720";
|
||||
}
|
||||
|
||||
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80" style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingGroup({ title, color, children }: { title: string; color: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-xs font-medium" style={{ color }}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolutionInput({ value, theme, onChange }: { value: string; theme: CanvasTheme; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="flex h-9 overflow-hidden rounded-full border text-sm" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
|
||||
<input type="number" min={1} className="min-w-0 flex-1 bg-transparent px-3 text-center outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" value={value} onChange={(event) => onChange(event.target.value)} onMouseDown={(event) => event.stopPropagation()} />
|
||||
<span className="grid w-7 place-items-center pr-1" style={{ color: theme.node.muted }}>
|
||||
p
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: CanvasTheme; onChange: (value: number | null) => void }) {
|
||||
return (
|
||||
<label className="flex h-9 overflow-hidden rounded-xl text-sm" style={{ background: theme.node.fill, color: theme.node.text, opacity: disabled ? 0.55 : 1 }}>
|
||||
<span className="grid w-9 place-items-center" style={{ color: theme.node.muted }}>
|
||||
{prefix}
|
||||
</span>
|
||||
<input type="number" min={1} disabled={disabled} className="min-w-0 flex-1 bg-transparent px-2 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" value={value || ""} onChange={(event) => onChange(Number(event.target.value) || null)} onMouseDown={(event) => event.stopPropagation()} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({ value, min, max, theme, onChange }: { value: string; min: number; max: number; theme: CanvasTheme; onChange: (value: string) => void }) {
|
||||
return <input type="number" min={min} max={max} className="h-9 rounded-full border bg-transparent px-3 text-center text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" style={{ borderColor: theme.node.stroke, color: theme.node.text, WebkitTextFillColor: theme.node.text }} value={value} onChange={(event) => onChange(event.target.value)} onMouseDown={(event) => event.stopPropagation()} />;
|
||||
}
|
||||
|
||||
function SizePreview({ width, height, color }: { width: number; height: number; color: string }) {
|
||||
if (!width || !height) return null;
|
||||
const longSide = Math.max(width, height);
|
||||
const previewWidth = Math.max(10, Math.round((width / longSide) * 26));
|
||||
const previewHeight = Math.max(10, Math.round((height / longSide) * 26));
|
||||
return <span className="rounded-[3px] border-2" style={{ width: previewWidth, height: previewHeight, borderColor: color }} />;
|
||||
}
|
||||
|
||||
function readSizeDimensions(size: string) {
|
||||
if (size === "auto") return { width: 0, height: 0 };
|
||||
const match = size.match(/^(\d+)x(\d+)$/);
|
||||
return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, ImagePlus, Images, Maximize2 } from "lucide-react";
|
||||
import { FileText, ImagePlus, Images, Maximize2, Video } from "lucide-react";
|
||||
|
||||
export const navigationTools = [
|
||||
{
|
||||
@@ -11,6 +11,11 @@ export const navigationTools = [
|
||||
label: "生图工作台",
|
||||
icon: ImagePlus,
|
||||
},
|
||||
{
|
||||
slug: "video",
|
||||
label: "视频创作台",
|
||||
icon: Video,
|
||||
},
|
||||
{
|
||||
slug: "prompts",
|
||||
label: "提示词库",
|
||||
|
||||
@@ -63,6 +63,13 @@ function resolveSize(quality: string, ratio: string): string | undefined {
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
function resolveRequestSize(quality: string | undefined, size: string) {
|
||||
const value = size.trim();
|
||||
if (!value || value === "auto") return undefined;
|
||||
if (/^\d+x\d+$/.test(value)) return value;
|
||||
return (quality && resolveSize(quality, value)) || value;
|
||||
}
|
||||
|
||||
function resolveImageDataUrl(item: Record<string, unknown>) {
|
||||
if (typeof item.b64_json === "string" && item.b64_json) {
|
||||
return `data:image/png;base64,${item.b64_json}`;
|
||||
@@ -146,7 +153,7 @@ function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[])
|
||||
export async function requestGeneration(config: AiConfig, prompt: string) {
|
||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const pixelSize = quality ? resolveSize(quality, config.size) : undefined;
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(
|
||||
aiApiUrl(config, "/images/generations"),
|
||||
@@ -155,7 +162,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
|
||||
prompt: withSystemPrompt(config, prompt),
|
||||
n,
|
||||
...(quality ? { quality } : {}),
|
||||
...(pixelSize ? { size: pixelSize } : {}),
|
||||
...(requestSize ? { size: requestSize } : {}),
|
||||
response_format: "b64_json",
|
||||
},
|
||||
{
|
||||
@@ -173,7 +180,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
|
||||
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) {
|
||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const pixelSize = quality ? resolveSize(quality, config.size) : undefined;
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const formData = new FormData();
|
||||
formData.set("model", config.model);
|
||||
formData.set("prompt", withSystemPrompt(config, prompt));
|
||||
@@ -182,8 +189,8 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (quality) {
|
||||
formData.set("quality", quality);
|
||||
}
|
||||
if (pixelSize) {
|
||||
formData.set("size", pixelSize);
|
||||
if (requestSize) {
|
||||
formData.set("size", requestSize);
|
||||
}
|
||||
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => formData.append("image", file));
|
||||
|
||||
@@ -33,25 +33,31 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
|
||||
body.append("preset", "normal");
|
||||
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => body.append("input_reference[]", file));
|
||||
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) })).data);
|
||||
if (!created.id) throw new Error("视频接口没有返回任务 ID");
|
||||
for (;;) {
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${created.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data);
|
||||
if (video.status === "completed") break;
|
||||
if (video.status === "failed" || video.status === "cancelled") throw new Error(video.error?.message || "视频生成失败");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
try {
|
||||
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) })).data);
|
||||
if (!created.id) throw new Error("视频接口没有返回任务 ID");
|
||||
for (;;) {
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${created.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data);
|
||||
if (video.status === "completed") break;
|
||||
if (video.status === "failed" || video.status === "cancelled") throw new Error(video.error?.message || "视频生成失败");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
}
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" });
|
||||
await assertVideoBlob(content.data);
|
||||
refreshRemoteUser(config);
|
||||
return content.data;
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "视频生成失败"));
|
||||
}
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" });
|
||||
await assertVideoBlob(content.data);
|
||||
refreshRemoteUser(config);
|
||||
return content.data;
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
return String(Math.max(1, Math.floor(Number(value) || 6)));
|
||||
const seconds = Math.floor(Number(value) || 6);
|
||||
return String(Math.max(1, Math.min(20, seconds)));
|
||||
}
|
||||
|
||||
function normalizeVideoSize(value: string) {
|
||||
if (value === "auto") return null;
|
||||
const size = value || "1280x720";
|
||||
if (/^\d+x\d+$/.test(size)) return size;
|
||||
return ["9:16", "2:3", "3:4"].includes(size) ? "720x1280" : "1280x720";
|
||||
@@ -74,6 +80,14 @@ function unwrapVideoResponse(payload: ApiVideoResponse) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readAxiosError(error: unknown, fallback: string) {
|
||||
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
|
||||
const responseData = error.response?.data;
|
||||
return responseData?.msg || responseData?.error?.message || (error.response?.status ? `${fallback}:${error.response.status}` : fallback);
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function assertVideoBlob(blob: Blob) {
|
||||
if (!blob.type.includes("json")) return;
|
||||
let payload: { code?: number; msg?: string };
|
||||
|
||||
Reference in New Issue
Block a user