mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 00:34:22 +08:00
feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections
This commit is contained in:
@@ -5,7 +5,6 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
@@ -18,31 +17,20 @@ type SeedanceTask = {
|
||||
content?: { video_url?: string; last_frame_url?: string } | null;
|
||||
};
|
||||
type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
|
||||
type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
|
||||
|
||||
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
|
||||
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
|
||||
return buildApiUrl(config.baseUrl, path);
|
||||
}
|
||||
|
||||
function aiHeaders(config: AiConfig, contentType?: string) {
|
||||
const token = useUserStore.getState().token;
|
||||
return config.channelMode === "remote"
|
||||
? {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
}
|
||||
: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRemoteUser(config: AiConfig) {
|
||||
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationResult> {
|
||||
@@ -102,11 +90,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
|
||||
|
||||
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
|
||||
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config) })).data);
|
||||
if (video.status === "completed") {
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob" });
|
||||
await assertVideoBlob(content.data);
|
||||
refreshRemoteUser(config);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
}
|
||||
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
|
||||
@@ -145,11 +132,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
|
||||
|
||||
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
|
||||
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config) })).data);
|
||||
if (state.status === "succeeded") {
|
||||
const url = state.content?.video_url;
|
||||
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
|
||||
refreshRemoteUser(config);
|
||||
return { status: "completed", result: await videoResultFromUrl(url) };
|
||||
}
|
||||
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
|
||||
@@ -182,7 +168,6 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
|
||||
}
|
||||
|
||||
function seedanceApiUrl(config: AiConfig, taskId?: string) {
|
||||
if (config.channelMode === "remote") return taskId ? `/api/v1/videos/${encodeURIComponent(taskId)}` : "/api/v1/videos";
|
||||
return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -207,9 +192,6 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
|
||||
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
|
||||
const dataUrl = await imageToDataUrl(image);
|
||||
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
|
||||
if (config.channelMode === "remote") {
|
||||
return uploadReferenceMedia(dataUrlToFile({ ...image, dataUrl }));
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
@@ -219,8 +201,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
|
||||
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
|
||||
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
|
||||
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
|
||||
const file = new File([blob], video.name || "reference-video.mp4", { type: video.type || blob.type || "video/mp4" });
|
||||
return uploadReferenceMedia(file);
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
@@ -229,19 +210,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
|
||||
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
|
||||
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
|
||||
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
|
||||
const file = new File([blob], audio.name || "reference-audio.mp3", { type: audio.type || blob.type || "audio/mpeg" });
|
||||
return uploadReferenceMedia(file);
|
||||
}
|
||||
|
||||
async function uploadReferenceMedia(file: File) {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) throw new Error("使用本地参考素材需要先登录,并在服务端配置 PUBLIC_BASE_URL");
|
||||
const body = new FormData();
|
||||
body.append("file", file, file.name);
|
||||
const response = await axios.post<ApiEnvelope<ReferenceMediaUploadResponse>>("/api/v1/media/references", body, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const payload = unwrapEnvelope(response.data, "参考素材上传失败");
|
||||
if (!payload.url) throw new Error("参考素材上传后没有返回公网 URL");
|
||||
return payload.url;
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
|
||||
@@ -256,8 +225,8 @@ async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
|
||||
|
||||
function assertVideoConfig(config: AiConfig, model: string) {
|
||||
if (!model) throw new Error("请先配置视频模型");
|
||||
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
@@ -330,3 +299,12 @@ function isPublicMediaUrl(value: string) {
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取本地素材失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user