diff --git a/src/app/api/_popiserverModels.ts b/src/app/api/_popiserverModels.ts index 50145396..580dfacf 100644 --- a/src/app/api/_popiserverModels.ts +++ b/src/app/api/_popiserverModels.ts @@ -255,11 +255,11 @@ export function buildPopiModelInputs(model: PopiAIModel): ModelInput[] { if (model.isSupportAudios) { inputs.push({ - name: "audios", + name: "voices", type: "audio", required: true, - label: "Audios", - description: "Input audios", + label: "Voices", + description: "Input voices", isArray: true, }); } diff --git a/src/app/api/generate/providers/__tests__/newapiwg.test.ts b/src/app/api/generate/providers/__tests__/newapiwg.test.ts index f82eb650..27a0e991 100644 --- a/src/app/api/generate/providers/__tests__/newapiwg.test.ts +++ b/src/app/api/generate/providers/__tests__/newapiwg.test.ts @@ -478,10 +478,14 @@ describe("NewApiWG generation payloads", () => { it("passes connected media inputs with NewApiWG gateway field names", async () => { const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ images: ["data:image/png;base64,abc"], - parameters: { duration: 5 }, + parameters: { + duration: 5, + audios: ["https://cdn.example.com/legacy-param-audio.mp3"], + }, dynamicInputs: { image: "data:image/png;base64,abc", - reference_audio_urls: ["data:audio/mpeg;base64,audio"], + voices: ["https://cdn.example.com/input-audio.mp3"], + reference_audio_urls: ["https://cdn.example.com/legacy-audio.mp3"], }, })); @@ -489,8 +493,9 @@ describe("NewApiWG generation payloads", () => { expect(capturedBody).not.toBeNull(); expect(capturedBody!.prompt).toBe("a dancing robot"); expect(capturedBody!.images).toEqual(["data:image/png;base64,abc"]); - expect(capturedBody!.audios).toEqual(["data:audio/mpeg;base64,audio"]); + expect(capturedBody!.voices).toEqual(["https://cdn.example.com/input-audio.mp3"]); expect(capturedBody!.image).toBeUndefined(); + expect(capturedBody!.audios).toBeUndefined(); expect(capturedBody!.reference_audio_urls).toBeUndefined(); expect(capturedBody!.duration).toBe(5); }); @@ -516,7 +521,7 @@ describe("NewApiWG generation payloads", () => { name: "viduq3-turbo", description: null, provider: "newapiwg", - capabilities: ["text-to-video", "image-to-video"], + capabilities: ["text-to-video", "image-to-video", "video-to-video"], }, dynamicInputs: { reference_video_urls: ["data:video/mp4;base64,reference"], @@ -534,6 +539,31 @@ describe("NewApiWG generation payloads", () => { expect(capturedBody!.video_urls).toBeUndefined(); }); + it("drops video references when the NewApiWG model does not support video inputs", async () => { + const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ + model: { + id: "text-video-only", + name: "Text Video Only", + description: null, + provider: "newapiwg", + capabilities: ["text-to-video"], + }, + dynamicInputs: { + videos: ["https://cdn.example.com/reference.mp4"], + }, + parameters: { + referenceSubjectList: [ + { id: 1, type: "video", url: "https://cdn.example.com/reference.mp4", name: "视频1" }, + ], + }, + })); + + expect(result.success).toBe(true); + expect(capturedBody).not.toBeNull(); + expect(capturedBody!.videos).toBeUndefined(); + expect(capturedBody!.referenceSubjectList).toBeUndefined(); + }); + it("resolves temporary local media inputs before NewApiWG video generation", async () => { const imageId = storeImage("data:image/png;base64,dmlkZW8tcmVm"); try { diff --git a/src/app/api/generate/providers/__tests__/popiserver.test.ts b/src/app/api/generate/providers/__tests__/popiserver.test.ts index 11874ad8..a8063931 100644 --- a/src/app/api/generate/providers/__tests__/popiserver.test.ts +++ b/src/app/api/generate/providers/__tests__/popiserver.test.ts @@ -271,7 +271,7 @@ describe("popiserver generation provider", () => { }); }); - it("uploads task input media in one batch before creating the task", async () => { + it("uploads task image and video inputs before creating the task and keeps voices as URLs", async () => { vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: true, @@ -281,7 +281,6 @@ describe("popiserver generation provider", () => { data: [ { url: "https://cdn.popi.art/input-image.png" }, { url: "https://cdn.popi.art/input-video.mp4" }, - { url: "https://cdn.popi.art/input-audio.mp3" }, ], }), } as Response) @@ -295,10 +294,20 @@ describe("popiserver generation provider", () => { } as Response); const input = makeInput(); + input.model.metadata = { + ...input.model.metadata, + isSupportAudios: true, + isSupportVideos: true, + }; input.images = ["data:image/png;base64,aW1hZ2U="]; + input.parameters = { + ...input.parameters, + audios: ["https://cdn.popi.art/legacy-param-audio.mp3"], + }; input.dynamicInputs = { videos: ["data:video/mp4;base64,dmlkZW8="], - audios: ["data:audio/mpeg;base64,YXVkaW8="], + voices: ["https://cdn.popi.art/input-audio.mp3"], + audio_url: "https://cdn.popi.art/legacy-audio-url.mp3", }; const result = await submitPopiTask(makeRequest(), "login-token", input); @@ -312,14 +321,16 @@ describe("popiserver generation provider", () => { expect(uploadInit?.body).toBeInstanceOf(FormData); const uploadBody = uploadInit?.body as FormData; expect(uploadBody.get("multiple")).toBe("true"); - expect(uploadBody.getAll("file")).toHaveLength(3); + expect(uploadBody.getAll("file")).toHaveLength(2); const [createUrl, createInit] = vi.mocked(global.fetch).mock.calls[1]; expect(String(createUrl)).toContain("/api_client/anime/task/create"); const createBody = JSON.parse(String(createInit?.body)); expect(createBody.images).toEqual(["https://cdn.popi.art/input-image.png"]); expect(createBody.videos).toEqual(["https://cdn.popi.art/input-video.mp4"]); - expect(createBody.audios).toEqual(["https://cdn.popi.art/input-audio.mp3"]); + expect(createBody.voices).toEqual(["https://cdn.popi.art/input-audio.mp3"]); + expect(createBody.audios).toBeUndefined(); + expect(createBody.audio_url).toBeUndefined(); }); it("accepts single-object media upload responses and reuses duplicate inputs", async () => { @@ -389,6 +400,10 @@ describe("popiserver generation provider", () => { } as Response); const input = makeInput(); + input.model.metadata = { + ...input.model.metadata, + isSupportVideos: true, + }; input.images = [ "https://already.cdn/source.png", "data:image/png;base64,aW1hZ2U=", @@ -440,13 +455,15 @@ describe("popiserver generation provider", () => { aiModelId: 29, aiModelCode: "seedance 2.0", aiModelCodeAlias: "doubao-seedance-2-0-fast-260128", + isSupportAudios: true, + isSupportVideos: true, }, }; input.prompt = "@图1的人物参考@视频2的人物的动作跳舞"; input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"]; input.dynamicInputs = { videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"], - audios: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"], + voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"], }; input.parameters = { subType: 203, @@ -503,7 +520,7 @@ describe("popiserver generation provider", () => { chatPrompt: "@图1的人物参考@视频2的人物的动作跳舞", images: ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"], videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"], - audios: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"], + voices: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"], referenceSubjectList: [ { id: 1, @@ -528,6 +545,63 @@ describe("popiserver generation provider", () => { }); }); + it("drops video references when the PopiServer model does not support video inputs", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + status: "0000", + message: "ok", + data: { id: 1195 }, + }), + } as Response); + + const input = makeInput(); + input.model.metadata = { + ...input.model.metadata, + isSupportVideos: false, + }; + input.dynamicInputs = { + videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"], + }; + input.parameters = { + ...input.parameters, + referenceSubjectList: [ + { + id: 1, + type: "image", + url: "stale-image-url", + name: "@图1", + }, + { + id: 2, + type: "video", + url: "stale-video-url", + name: "@视频2", + }, + ], + }; + + await submitPopiTask(makeRequest(), "login-token", input); + + const [, init] = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(String(init?.body)); + expect(body.videos).toBeUndefined(); + expect(body.referenceSubjectList).toEqual([ + { + id: 1, + type: "image", + url: "https://example.com/1.png", + name: "图1", + }, + { + id: 2, + type: "image", + url: "https://example.com/2.png", + name: "图2", + }, + ]); + }); + it("extracts completed image task output from task list", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: true, diff --git a/src/app/api/generate/providers/newapiwg.ts b/src/app/api/generate/providers/newapiwg.ts index 125e60e8..f38bf28c 100644 --- a/src/app/api/generate/providers/newapiwg.ts +++ b/src/app/api/generate/providers/newapiwg.ts @@ -4,6 +4,8 @@ import { resolveTemporaryImageUrlToDataUrl } from "@/lib/images/temporaryUrls"; import { validateMediaUrl } from "@/utils/urlValidation"; import { buildGatewayHeaders } from "@/app/api/_auth"; import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging"; +import type { SelectedModel } from "@/types"; +import { filterReferenceSubjectList, modelSupportsReferenceMedia } from "@/utils/generationInputSupport"; type NewApiWGModelObject = { id?: string; @@ -819,6 +821,10 @@ const NEWAPIWG_VIDEO_INPUTS = new Set([ ]); const NEWAPIWG_VIDEO_AUDIO_INPUTS = new Set([ + "voices", +]); + +const NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS = new Set([ "audio", "audios", "audio_url", @@ -874,13 +880,17 @@ function normalizeNewApiWGVideoInputs( const normalizedDynamicInputs: Record = {}; const images = [...(input.images || [])]; const videos: string[] = []; - const audios: string[] = []; + const voices: string[] = []; const shouldUseViduReferenceVideoField = isViduVideoModel(input.model.id); + const selectedModel = toSelectedModel(input.model); + const supportsVideoReference = modelSupportsReferenceMedia(selectedModel, "video", undefined, input.parameters); + const supportsAudioReference = modelSupportsReferenceMedia(selectedModel, "audio", undefined, input.parameters); for (const [key, value] of Object.entries(dynamicInputs)) { if (NEWAPIWG_VIDEO_IMAGE_INPUTS.has(key)) { images.push(...asStringArray(value)); } else if (NEWAPIWG_VIDEO_INPUTS.has(key)) { + if (!supportsVideoReference) continue; const videoValues = asStringArray(value); if (shouldUseViduReferenceVideoField) { appendDynamicInputArray(normalizedDynamicInputs, "reference_video_urls", videoValues); @@ -888,7 +898,10 @@ function normalizeNewApiWGVideoInputs( videos.push(...videoValues); } } else if (NEWAPIWG_VIDEO_AUDIO_INPUTS.has(key)) { - audios.push(...asStringArray(value)); + if (!supportsAudioReference) continue; + voices.push(...asStringArray(value)); + } else if (NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS.has(key)) { + continue; } else { normalizedDynamicInputs[key] = value; } @@ -897,14 +910,47 @@ function normalizeNewApiWGVideoInputs( const mediaInputs: Record = {}; const uniqueImages = uniqueStrings(images); const uniqueVideos = uniqueStrings(videos); - const uniqueAudios = uniqueStrings(audios); + const uniqueVoices = uniqueStrings(voices); if (uniqueImages.length > 0) mediaInputs.images = uniqueImages; if (uniqueVideos.length > 0) mediaInputs.videos = uniqueVideos; - if (uniqueAudios.length > 0) mediaInputs.audios = uniqueAudios; + if (uniqueVoices.length > 0) mediaInputs.voices = uniqueVoices; return { dynamicInputs: normalizedDynamicInputs, mediaInputs }; } +function toSelectedModel(model: GenerationInput["model"]): SelectedModel { + return { + provider: model.provider, + modelId: model.id, + displayName: model.name, + capabilities: model.capabilities, + metadata: model.metadata, + }; +} + +function sanitizeNewApiWGVideoParameters( + model: GenerationInput["model"], + parameters: Record | undefined +): Record | undefined { + if (!parameters) return undefined; + const normalized = { ...parameters }; + for (const key of NEWAPIWG_LEGACY_VIDEO_AUDIO_INPUTS) { + delete normalized[key]; + } + if (Object.prototype.hasOwnProperty.call(normalized, "referenceSubjectList")) { + normalized.referenceSubjectList = filterReferenceSubjectList( + normalized.referenceSubjectList, + toSelectedModel(model), + undefined, + parameters + ); + if (Array.isArray(normalized.referenceSubjectList) && normalized.referenceSubjectList.length === 0) { + delete normalized.referenceSubjectList; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + function buildNewApiWGImageInputs(input: GenerationInput): Record { if (!input.images?.length) return {}; return { image: input.images.length === 1 ? input.images[0] : input.images }; @@ -1587,7 +1633,7 @@ export async function generateWithNewApiWG( ...normalizedDynamicInputs, ...mediaInputs, ...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}), - ...(isVideo ? (input.parameters || {}) : imageParameters), + ...(isVideo ? sanitizeNewApiWGVideoParameters(input.model, input.parameters) : imageParameters), }), }, input.model.name, `NewApiWG ${isVideo ? "video" : "image"} API error`); diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index 15c57d26..f3b76c6d 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/src/app/api/generate/providers/popiserver.ts @@ -8,6 +8,7 @@ import { } from "@/app/api/_popiserverModels"; import { ApiError } from "@/app/api/_errors"; import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging"; +import { modelSupportsReferenceMedia } from "@/utils/generationInputSupport"; const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create"; const POPISERVER_TASK_LIST_PATH = "/api_client/anime/task/list"; @@ -34,14 +35,21 @@ const POPISERVER_VIDEO_DYNAMIC_KEYS = [ "video_urls", ]; -const POPISERVER_AUDIO_DYNAMIC_KEYS = [ +const POPISERVER_VOICE_DYNAMIC_KEYS = [ + "voices", +]; + +const POPISERVER_LEGACY_AUDIO_KEYS = [ "audio", "audios", "audio_url", "audio_urls", + "reference_audio", + "reference_audio_url", + "reference_audio_urls", ]; -const POPISERVER_VOICE_DYNAMIC_KEYS = [ +const POPISERVER_VOICE_ID_DYNAMIC_KEYS = [ "voiceId", "voice_id", ]; @@ -49,7 +57,7 @@ const POPISERVER_VOICE_DYNAMIC_KEYS = [ const POPISERVER_MEDIA_DYNAMIC_KEYS = [ ...POPISERVER_IMAGE_DYNAMIC_KEYS, ...POPISERVER_VIDEO_DYNAMIC_KEYS, - ...POPISERVER_AUDIO_DYNAMIC_KEYS, + ...POPISERVER_VOICE_DYNAMIC_KEYS, ]; type PopiserverResponse = { @@ -465,10 +473,18 @@ function normalizeReferenceSubjectName(name: string): string { return name.replace(/^@+/, ""); } +function removeLegacyAudioParameters(parameters: Record): Record { + const normalized = { ...parameters }; + POPISERVER_LEGACY_AUDIO_KEYS.forEach((key) => { + delete normalized[key]; + }); + return normalized; +} + function buildReferenceSubjectList( images: string[], videos: string[], - audios: string[], + voices: string[], parameters: Record ): ReferenceSubject[] { const metadata = referenceSubjectMetadata(parameters); @@ -502,7 +518,7 @@ function buildReferenceSubjectList( }); }); - audios.forEach((url, index) => { + voices.forEach((url, index) => { const id = subjects.length + 1; const meta = metadataByType.audio[index]; subjects.push({ @@ -517,7 +533,16 @@ function buildReferenceSubjectList( } function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | null): Record { - const parameters = input.parameters || {}; + const rawParameters = removeLegacyAudioParameters(input.parameters || {}); + const parameters = { ...rawParameters }; + delete parameters.referenceSubjectList; + const selectedModel = { + provider: "popiserver" as const, + modelId: input.model.id, + displayName: input.model.name, + capabilities: input.model.capabilities, + metadata: input.model.metadata, + }; const type = asNumber(parameters.type) ?? inferTaskType(input); const subType = asNumber(parameters.subType) ?? inferTaskSubType(input, type); const aspectRatio = resolveAspectRatio(parameters); @@ -538,13 +563,17 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu ...(input.images || []), ...collectDynamicMedia(input.dynamicInputs, POPISERVER_IMAGE_DYNAMIC_KEYS), ]; - const videos = collectDynamicMedia(input.dynamicInputs, POPISERVER_VIDEO_DYNAMIC_KEYS); - const audios = collectDynamicMedia(input.dynamicInputs, POPISERVER_AUDIO_DYNAMIC_KEYS); + const videos = modelSupportsReferenceMedia(selectedModel, "video", undefined, rawParameters) + ? collectDynamicMedia(input.dynamicInputs, POPISERVER_VIDEO_DYNAMIC_KEYS) + : []; + const voices = modelSupportsReferenceMedia(selectedModel, "audio", undefined, rawParameters) + ? collectDynamicMedia(input.dynamicInputs, POPISERVER_VOICE_DYNAMIC_KEYS) + : []; const referenceSubjectList = type === 1 || type === 2 - ? buildReferenceSubjectList([...new Set(images)], [...new Set(videos)], [...new Set(audios)], parameters) + ? buildReferenceSubjectList([...new Set(images)], [...new Set(videos)], [...new Set(voices)], rawParameters) : []; const voiceId = - firstDynamicString(input.dynamicInputs, POPISERVER_VOICE_DYNAMIC_KEYS) ?? + firstDynamicString(input.dynamicInputs, POPISERVER_VOICE_ID_DYNAMIC_KEYS) ?? asString(parameters.voiceId) ?? ""; @@ -566,7 +595,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu return { ...parameters, ...commonBody, - ...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}), + ...(voices.length > 0 ? { voices: [...new Set(voices)] } : {}), ...(voiceId ? { voiceId } : {}), }; } @@ -576,7 +605,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu ...commonBody, ...(images.length > 0 ? { images: [...new Set(images)] } : {}), ...(videos.length > 0 ? { videos: [...new Set(videos)] } : {}), - ...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}), + ...(voices.length > 0 ? { voices: [...new Set(voices)] } : {}), ...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}), styleId: asNumber(parameters.styleId) ?? 0, width: asNumber(parameters.width) ?? dimensions.width, diff --git a/src/app/api/popi/character/list/route.ts b/src/app/api/popi/character/list/route.ts new file mode 100644 index 00000000..81bd0f2e --- /dev/null +++ b/src/app/api/popi/character/list/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireLogin } from "@/app/api/_auth"; +import { ApiError } from "@/app/api/_errors"; + +const POPI_CHARACTER_LIST_PATH = "/api_client/anime/character/list"; + +type PopiserverResponse = { + status?: string; + message?: string; + data?: T; +}; + +function getPopiBaseUrl(request: NextRequest): string { + const baseUrl = + process.env.POPIART_API_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + request.nextUrl.origin; + return baseUrl.replace(/\/+$/, ""); +} + +export async function GET(request: NextRequest) { + try { + const login = requireLogin(request); + const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_CHARACTER_LIST_PATH); + request.nextUrl.searchParams.forEach((value, key) => { + upstreamUrl.searchParams.set(key, value); + }); + if (!upstreamUrl.searchParams.has("keyword")) { + upstreamUrl.searchParams.set("keyword", ""); + } + + const upstream = await fetch(upstreamUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${login.token}`, + token: login.token, + }, + cache: "no-store", + }); + + const payload = (await upstream.json().catch(() => null)) as PopiserverResponse | null; + if (!upstream.ok) { + throw ApiError.provider(payload?.message || "Popiserver character list failed.", upstream.status); + } + if (!payload || payload.status !== "0000") { + throw ApiError.provider(payload?.message || "Popiserver character list returned an error.", 502); + } + + return NextResponse.json({ + success: true, + data: payload.data, + }); + } catch (error) { + if (error instanceof ApiError) { + return NextResponse.json({ success: false, error: error.message }, { status: error.status }); + } + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Character list failed." }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index d22cd34f..4519faaf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -62,6 +62,25 @@ body { pointer-events: none; } +.canvas-default-cursor .react-flow, +.canvas-default-cursor .react-flow__pane { + cursor: default !important; +} + +.canvas-pan-ready .react-flow, +.canvas-pan-ready .react-flow__pane, +.canvas-pan-ready .react-flow__node, +.canvas-pan-ready .react-flow__node * { + cursor: grab !important; +} + +.canvas-pan-active .react-flow, +.canvas-pan-active .react-flow__pane, +.canvas-pan-active .react-flow__node, +.canvas-pan-active .react-flow__node * { + cursor: grabbing !important; +} + /* Remove default React Flow styling for output nodes to match custom nodes */ .react-flow__node-output { border: none !important; diff --git a/src/components/CharacterPickerModal.tsx b/src/components/CharacterPickerModal.tsx new file mode 100644 index 00000000..435df114 --- /dev/null +++ b/src/components/CharacterPickerModal.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Pagination } from "antd"; +import { authFetch } from "@/utils/authFetch"; +import { useI18n } from "@/i18n"; + +export interface CharacterItem { + id: number; + title?: string; + description?: string; + avatar?: string; + image?: string; + characterName?: string; + defaultPrompt?: string; +} + +interface CharacterPickerModalProps { + onClose: () => void; + onSelect: (character: CharacterItem) => void; +} + +interface CharacterListPayload { + list?: CharacterItem[]; + pageInfo?: { + page?: number; + pageSize?: number; + pageCount?: number; + total?: number; + }; +} + +interface CharacterListResponse { + success?: boolean; + data?: CharacterListPayload; + error?: string; +} + +const PAGE_SIZE = 20; + +function getCharacterTitle(character: CharacterItem): string { + return character.title?.trim() || `#${character.id}`; +} + +export function CharacterPickerModal({ onClose, onSelect }: CharacterPickerModalProps) { + const { t } = useI18n(); + const [page, setPage] = useState(1); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadCharacters = useCallback(async (signal: AbortSignal) => { + setIsLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + keyword: "", + page: String(page), + pageSize: String(PAGE_SIZE), + }); + const response = await authFetch(`/api/popi/character/list?${params.toString()}`, { signal }); + const payload = (await response.json().catch(() => null)) as CharacterListResponse | null; + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || t("characterPicker.loadFailed")); + } + + const pageInfo = payload.data?.pageInfo; + setItems(payload.data?.list || []); + setTotal(pageInfo?.total ?? (pageInfo?.pageCount ?? 1) * (pageInfo?.pageSize ?? PAGE_SIZE)); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + setError(err instanceof Error ? err.message : t("characterPicker.loadFailed")); + setItems([]); + setTotal(0); + } finally { + if (!signal.aborted) setIsLoading(false); + } + }, [page, t]); + + useEffect(() => { + const controller = new AbortController(); + void loadCharacters(controller.signal); + return () => controller.abort(); + }, [loadCharacters]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const content = useMemo(() => { + if (isLoading) { + return ( +
+ {t("characterPicker.loading")} +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + const selectableItems = items.filter((item) => item.image && (item.avatar || item.image)); + if (selectableItems.length === 0) { + return ( +
+ {t("characterPicker.empty")} +
+ ); + } + + return ( +
+ {selectableItems.map((character) => { + const title = getCharacterTitle(character); + const previewUrl = character.avatar || character.image; + if (!previewUrl || !character.image) return null; + + return ( + + ); + })} +
+ ); + }, [error, isLoading, items, loadCharacters, onSelect, t]); + + if (typeof document === "undefined") return null; + + return createPortal( +
+
event.stopPropagation()} + > +
+

{t("characterPicker.title")}

+ +
+ + {content} + +
+ +
+
+
, + document.body + ); +} diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index a85ace6e..7382e4e4 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { CloseOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons"; +import { PictureOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons"; import { useWorkflowStore } from "@/store/workflowStore"; import { NodeType } from "@/types"; import { useReactFlow } from "@xyflow/react"; @@ -10,6 +10,7 @@ import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeMod import { GenerateNodeMenu } from "./GenerateNodeMenu"; import { ModelSearchDialog } from "./modals/ModelSearchDialog"; import { AssetPickerModal, PopiAssetItem } from "./AssetPickerModal"; +import { CharacterPickerModal, CharacterItem } from "./CharacterPickerModal"; import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi"; import { useFTUXStore, TutorialStep } from "@/store/ftuxStore"; @@ -198,6 +199,59 @@ function ResourceImageButton() { ); } +function CharacterButton() { + const { t } = useI18n(); + const [isCharacterPickerOpen, setIsCharacterPickerOpen] = useState(false); + const addNode = useWorkflowStore((state) => state.addNode); + const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); + const { screenToFlowPosition } = useReactFlow(); + + const handleSelectCharacter = (character: CharacterItem) => { + if (!character.image) return; + + const center = getPaneCenter(); + const position = screenToFlowPosition({ + x: center.x + Math.random() * 100 - 50, + y: center.y + Math.random() * 100 - 50, + }); + const title = character.title?.trim() || `character-${character.id}`; + + const nodeId = addNode("smartImage", position, { + image: character.image, + imageRef: undefined, + previewImage: character.avatar || character.image, + assetDetailLoading: false, + assetDetailError: null, + filename: title, + dimensions: null, + }); + + selectSingleNode(nodeId); + setIsCharacterPickerOpen(false); + }; + + return ( + <> + + + {isCharacterPickerOpen && ( + setIsCharacterPickerOpen(false)} + onSelect={handleSelectCharacter} + /> + )} + + ); +} + export function FloatingActionBar() { const { t } = useI18n(); const nodes = useWorkflowStore((state) => state.nodes) ?? []; @@ -331,6 +385,7 @@ export function FloatingActionBar() {
+ + ); + })} +
+ , + document.body + ) + : null; return ( <> + {assistMenuOverlay} {!hideComposerForModal && shouldRenderInlineComposer && inlineComposerPosition && (
-
- {isComposerCollapsed ? ( -
-
- - - - {isProcessNode ? ( +
+ {isComposerCollapsed ? ( +
+
+ + + + {isProcessNode ? ( + + ) : ( + + )} + + {!isProcessNode && ( + + )} +
+ - ) : ( + - )} - - {!isProcessNode && ( - - )} -
- - - -
) : ( - - - - - )} - -
- ) : ( - <> -
- - - - - - -
- - {hasComposerMediaHeader && ( -
- {isVideoSubTypeSelectorVisible && ( - { - const nextSubType = videoGenerationSubTypeFromUnknown(value); - if (!nextSubType) return; - if (nextSubType === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE && !canUseImageToVideo) { - message.warning(t("composer.videoSubTypeImageToVideoImageCountRequired")); - return; - } - if (nextSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && !canUseFirstLastFrame) { - message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired")); - return; - } - updateSchemaDraftParameter("subType", nextSubType); - }} - className="nodrag nopan nowheel max-w-full text-xs" - /> - )} - - {isProcessNode ? ( - isVideoStitchNode ? ( -
- - {t("composer.videoStitchClipCount", { count: connectedInputs?.videos.length ?? 0 })} - - - - {connectedInputs?.audio.length ? t("composer.videoStitchAudio") : t("composer.videoStitchNoAudio")} - -
- ) : ( -
- - {connectedInputs?.videos.length ? t("composer.easeCurveVideoConnected") : t("composer.easeCurveNoVideo")} - - - - {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")} - -
- ) - ) : connectedTextItems.length > 0 || referenceMaterials.length > 0 ? ( -
- {connectedTextItems.map((item, index) => ( - -
- - - {index + 1} - + <> +
+ + + + + + +
+ + {hasComposerMediaHeader && ( +
+ {isVideoSubTypeSelectorVisible && ( +
+ { + const nextSubType = videoGenerationSubTypeFromUnknown(value); + if (!nextSubType) return; + if (nextSubType === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE && !canUseImageToVideo) { + message.warning(t("composer.videoSubTypeImageToVideoImageCountRequired")); + return; + } + if (nextSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && !canUseFirstLastFrame) { + message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired")); + return; + } + updateSchemaDraftParameter("subType", nextSubType); + }} + className="nodrag nopan nowheel max-w-full text-xs" + />
- - ))} - {referenceMaterials.map((material, materialIndex) => ( -
handleReferenceMaterialDragStart(event, material)} - onDragOver={(event) => event.preventDefault()} - onDrop={(event) => handleReferenceMaterialDrop(event, material)} - className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900" - > - + ) : null} +
+ + ))} + {referenceMaterials.map((material) => ( + + {material.type === "image" ? ( + + ) : material.type === "video" ? ( +
+ )} + overlayClassName="[&_.ant-popover-inner]:!bg-black [&_.ant-popover-inner]:!p-0 [&_.ant-popover-arrow:before]:!bg-black" + > +
handleReferenceMaterialDragStart(event, material)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => handleReferenceMaterialDrop(event, material)} + aria-label={material.type === "image" + ? t("composer.referenceImage", { index: material.id }) + : material.name} + className="group relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70" + > + {material.type === "image" ? ( + + ) : material.type === "video" ? ( +
+ + ))} +
+ ) : null} +
+ )} + +
+ {isVideoStitchNode ? ( +
+
+
+ + {t("composer.videoStitchInputs")} + + + {t("composer.videoStitchInputHint")} + +
+ {videoStitchClipSummaries.length === 0 ? ( +
+ {t("composer.videoStitchNeedTwo")} +
) : ( -
- - - - - +
+ {videoStitchClipSummaries.map((clip) => ( +
+ +
+
+ {t("composer.videoStitchClip", { index: clip.index })} +
+
+ {clip.title} 璺?{clip.handle} +
+
+
+ ))}
)} - - - {isFirstLastFrameSelected && material.type === "image" - ? material.id === 1 - ? t("composer.firstFrame") - : t("composer.lastFrame") - : material.id} - +
+ +
+
+ {t("composer.videoStitchLoop")} +
+
+ {([1, 2, 3] as VideoStitchLoopCount[]).map((count) => ( + + ))} +
+
+ {videoStitchData?.outputVideo + ? t("composer.videoStitchOutputReady") + : t("composer.videoStitchNoOutput")} +
+
- ))} -
- ) : null} -
- )} - -
- {assistMenu && ( -
-
- {assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")} -
-
- {activeAssistItems.map((item, itemIndex) => { - const isActive = itemIndex === assistMenuActiveIndex; - return ( - - ); - })} -
-
- )} - - {isVideoStitchNode ? ( -
-
-
- - {t("composer.videoStitchInputs")} - - - {t("composer.videoStitchInputHint")} - -
- {videoStitchClipSummaries.length === 0 ? ( -
- {t("composer.videoStitchNeedTwo")} -
- ) : ( -
- {videoStitchClipSummaries.map((clip) => ( -
- -
-
- {t("composer.videoStitchClip", { index: clip.index })} + ) : isEaseCurveNode ? ( +
+
+
+ + {t("composer.easeCurveSettings")} + + + {connectedInputs?.easeCurve ? t("composer.easeCurveInherited") : t("composer.easeCurveLocal")} + +
+
+
+ + + + +
+
+
+
{t("composer.easeCurvePreset")}
+
+ {easeCurveData?.easingPreset ?? t("composer.easeCurveCustom")} +
+
+
+
{t("composer.easeCurveHandles")}
+
+ {formatBezierHandles(easeCurveData?.bezierHandles ?? [0.42, 0, 0.58, 1])} +
+
+
+ {processVideoInput?.hasVideo + ? `${t("composer.easeCurveInput")}: ${processVideoInput.title}` + : t("composer.easeCurveNoVideo")} +
+
-
- {clip.title} 璺?{clip.handle} +
+ +
+ + { + if (!context.node) return; + const value = Number.parseFloat(event.target.value); + updateNodeData(context.node.id, { + outputDuration: Number.isFinite(value) ? Math.max(0.1, Math.min(30, value)) : 1.5, + }); + }} + className="mt-2 w-full rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1.5 text-xs text-neutral-100 outline-none focus:border-blue-500" + /> +
+ {easeCurveData?.outputVideo + ? t("composer.easeCurveOutputReady") + : t("composer.easeCurveNoOutput")}
- ))} + ) : context.mode === "unsupported-node" || context.mode === "multi-select" ? ( +
+ {context.mode === "multi-select" + ? t("composer.multiSelectUnsupported", { count: context.selectedCount }) + : t("composer.unsupportedNodeHint")} +
+ ) : ( + <> +