diff --git a/docs/pending-test.md b/docs/pending-test.md index dc7f0d0..fb0fff5 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -4,3 +4,7 @@ - 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。 - 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。 - 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。 +- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name`、`preset=normal`、`input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。 +- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。 +- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。 +- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。 diff --git a/handler/ai.go b/handler/ai.go index b0289e3..f2c3389 100644 --- a/handler/ai.go +++ b/handler/ai.go @@ -41,7 +41,7 @@ func AIVideoContent(w http.ResponseWriter, r *http.Request, id string) { func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) { modelName := r.URL.Query().Get("model") if strings.TrimSpace(modelName) == "" { - modelName = "sora-2" + modelName = "grok-imagine-video" } channel, err := service.SelectModelChannel(modelName) if err != nil { diff --git a/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx b/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx index 30a53b3..a748318 100644 --- a/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx +++ b/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx @@ -9,15 +9,16 @@ import { useThemeStore } from "@/stores/use-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; import { CanvasImageSettingsTheme } from "./canvas-image-settings-popover"; -const qualityOptions = [ - { value: "auto", label: "自动" }, - { value: "high", label: "高" }, - { value: "medium", label: "中" }, - { value: "low", label: "低" }, +const resolutionOptions = [ + { value: "720", label: "720p" }, + { value: "480", label: "480p" }, ]; const sizeOptions = [ - { value: "1280x720", label: "横屏" }, - { value: "720x1280", label: "竖屏" }, + { 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]; @@ -30,9 +31,14 @@ type CanvasVideoSettingsPopoverProps = { export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const seconds = Math.max(1, Math.min(60, Math.floor(Math.abs(Number(config.videoSeconds)) || 6))); + const seconds = config.videoSeconds || "6"; const size = normalizeVideoSize(config.size); - const vquality = config.vquality || "auto"; + 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 ( triggerNode.parentElement || document.body} + zIndex={1200} + getPopupContainer={() => document.body} content={ -
event.stopPropagation()}> +
event.stopPropagation()}>
视频设置
- -
- {qualityOptions.map((item) => ( - onConfigChange("vquality", item.value)}> + +
+ {resolutionOptions.map((item) => ( + onConfigChange("vquality", item.value)}> {item.label} ))}
+ onConfigChange("vquality", value)} />
-
+
+ updateDimension("width", value)} /> + + updateDimension("height", value)} /> +
+
{sizeOptions.map((item) => ( - onConfigChange("size", item.value)}> - {item.label} - + ))}
{secondOptions.map((value) => ( - onConfigChange("videoSeconds", String(value))}> + onConfigChange("videoSeconds", String(value))}> {value}s ))} onConfigChange("videoSeconds", String(Number(event.target.value) || 6))} + onChange={(event) => onConfigChange("videoSeconds", event.target.value)} onMouseDown={(event) => event.stopPropagation()} />
@@ -89,7 +110,7 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass > @@ -115,15 +136,65 @@ function SettingGroup({ title, color, children }: { title: string; color: string ); } +function ResolutionInput({ value, theme, onChange }: { value: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: string) => void }) { + return ( + + ); +} + +function DimensionInput({ prefix, value, theme, onChange }: { prefix: string; value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) { + return ( + + ); +} + +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 ; +} + function normalizeVideoSize(value: string) { if (/^\d+x\d+$/.test(value || "")) return value; return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720"; } -function qualityLabel(value: string) { - return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record)[value] || value; +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 value === "720x1280" ? "竖屏" : "横屏"; + return ({ "720x1280": "竖屏", "1280x720": "横屏", "1024x1024": "方形", "1792x1024": "宽屏", "1024x1792": "长图" } as Record)[value] || value; } diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index c54d45e..23e83f6 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -7,6 +7,7 @@ import { useUserStore } from "@/stores/use-user-store"; import type { ReferenceImage } from "@/types/image"; type VideoResponse = { id: string; status?: string; error?: { message?: string } }; +type ApiVideoResponse = VideoResponse | { code?: number; data?: VideoResponse | null; msg?: string }; function aiApiUrl(config: AiConfig, path: string) { return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path); @@ -28,16 +29,20 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r body.append("prompt", prompt); body.append("seconds", normalizeVideoSeconds(config.videoSeconds)); if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!); - if (config.vquality) body.append("vquality", config.vquality); - if (references[0]) body.append("input_reference", dataUrlToFile({ ...references[0], dataUrl: await imageToDataUrl(references[0]) })); - const created = await axios.post(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) }); + body.append("resolution_name", normalizeVideoResolution(config.vquality)); + 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(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) })).data); + if (!created.id) throw new Error("视频接口没有返回任务 ID"); for (;;) { - const video = await axios.get(aiApiUrl(config, `/videos/${created.data.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined }); - if (video.data.status === "completed") break; - if (video.data.status === "failed" || video.data.status === "cancelled") throw new Error(video.data.error?.message || "视频生成失败"); + const video = unwrapVideoResponse((await axios.get(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(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" }); + const content = await axios.get(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; } @@ -51,3 +56,31 @@ function normalizeVideoSize(value: string) { if (/^\d+x\d+$/.test(size)) return size; return ["9:16", "2:3", "3:4"].includes(size) ? "720x1280" : "1280x720"; } + +function normalizeVideoResolution(value: string) { + if (value === "low") return "480p"; + if (value === "auto" || value === "high" || value === "medium") return "720p"; + const resolution = value.replace(/p$/i, "") || "720"; + return `${resolution}p`; +} + +function unwrapVideoResponse(payload: ApiVideoResponse) { + if (!payload) throw new Error("接口没有返回视频任务"); + if ("code" in payload && typeof payload.code === "number") { + if (payload.code !== 0) throw new Error(payload.msg || "请求失败"); + if (!payload.data) throw new Error("接口没有返回视频任务"); + return payload.data; + } + return payload; +} + +async function assertVideoBlob(blob: Blob) { + if (!blob.type.includes("json")) return; + let payload: { code?: number; msg?: string }; + try { + payload = JSON.parse(await blob.text()) as { code?: number; msg?: string }; + } catch { + return; + } + if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "视频下载失败"); +} diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 07ea71a..c4ba0f5 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -32,10 +32,10 @@ export const defaultConfig: AiConfig = { apiKey: "", model: "gpt-image-2", imageModel: "gpt-image-2", - videoModel: "sora-2", + videoModel: "grok-imagine-video", textModel: "gpt-5.5", videoSeconds: "6", - vquality: "auto", + vquality: "720", systemPrompt: "", models: [], quality: "auto", @@ -112,7 +112,7 @@ export const useConfigStore = create()( partialize: (state) => ({ config: state.config }), merge: (persisted, current) => { const config = { ...defaultConfig, ...((persisted as Partial).config || {}) }; - return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "sora-2", textModel: config.textModel || config.model, videoSeconds: config.videoSeconds || "6", vquality: config.vquality || "auto" } }; + return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "grok-imagine-video", textModel: config.textModel || config.model, videoSeconds: config.videoSeconds || "6", vquality: config.vquality || "720" } }; }, }, ),