fix: improve canvas local edit output
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-21 10:25:19 +08:00
parent ce283d287f
commit 61da591a5c
10 changed files with 731 additions and 202 deletions

View File

@ -86,11 +86,64 @@ const DEFAULT_REGION_COLORS = [
"#ff2d55",
"#8d6e63",
];
const SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
] as const;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function parseAspectRatioValue(ratio: string) {
const [width, height] = ratio.split(":").map((part) => Number(part));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
function inferClosestAspectRatio(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return "1:1";
}
const sourceRatio = width / height;
let closest: (typeof SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS)[number] = "1:1";
let closestDiff = Number.POSITIVE_INFINITY;
for (const candidate of SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS) {
const diff = Math.abs(parseAspectRatioValue(candidate) - sourceRatio);
if (diff < closestDiff) {
closest = candidate;
closestDiff = diff;
}
}
return closest;
}
function resolveLocalEditAspectRatio(input: unknown, sourceWidth: number, sourceHeight: number) {
const requested = String(input || "").trim();
if (
requested &&
requested.toLowerCase() !== "auto" &&
(SUPPORTED_LOCAL_EDIT_ASPECT_RATIOS as readonly string[]).includes(requested)
) {
return requested;
}
return inferClosestAspectRatio(sourceWidth, sourceHeight);
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
@ -331,7 +384,7 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
region.referenceImages.length > 0
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
: "";
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
})
.join("\n");
const referenceLines = regions
@ -345,17 +398,17 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。",
"输入图片3及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
"请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
"如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。",
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
"输入图片2及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
"最终结果必须保持输入图片1的原始画幅比例和构图边界,不要把画面压扁、拉伸、加白边或改成方图。",
"请不要凭空生成任何彩色框、蓝框、绿框、红框、矩形边框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
"",
"请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:",
"请严格根据每个区域下方的提示词,只在对应坐标区域附近完成局部编辑:",
regionLines,
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
"",
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
"未被彩色框标注的区域必须尽量保持原图不变。",
"未被列为编辑区域的画面必须尽量保持原图不变。",
"新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。",
"如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。",
].join("\n");
@ -434,19 +487,25 @@ export async function POST(request: NextRequest) {
const regionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
const resolvedAspectRatio = resolveLocalEditAspectRatio(
body.aspectRatio,
sourceWidth,
sourceHeight
);
const imageRequest: CanvasGenerateImageRequest = {
aspectRatio:
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
? body.aspectRatio.trim()
: "auto",
aspectRatio: resolvedAspectRatio,
billingFeature: "repaint",
debugLabel: "canvas-local-edit",
debugMeta: {
annotatedReferenceUrl: annotatedUpload.url,
annotatedReferenceSentToModel: false,
resolvedAspectRatio,
regionReferenceImageCount: regionReferenceImageUrls.length,
regionCount: regions.length,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
},
imageCount: 1,
imageSize:
@ -458,12 +517,11 @@ export async function POST(request: NextRequest) {
? body.model.trim()
: undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框",
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
prompt,
promptExtend: false,
referenceImages: [
sourceImageUrl,
annotatedUpload.url,
...regionReferenceImageUrls,
],
ownerKey: session.user.id,
@ -492,8 +550,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
annotatedReferenceUrl: annotatedUpload.url,
aspectRatio: resolvedAspectRatio,
prompt,
regions,
sourceHeight,
sourceWidth,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,

View File

@ -4236,6 +4236,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
generated: true,
generationProgress: 100,
generationStatus: "complete",
localEditMode: false,
localEditRegions: [],
mediaUrl: generatedUrl,
modelRef: task.result?.modelRef,
params: {
@ -4348,8 +4350,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
return;
}
const sourceWidth = Number(response.data.sourceWidth || 0);
const sourceHeight = Number(response.data.sourceHeight || 0);
const sourceRatio =
sourceWidth > 0 && sourceHeight > 0 ? sourceWidth / sourceHeight : 0;
const resultWidth = anchor.width;
const resultHeight = anchor.height;
const resultHeight =
sourceRatio > 0
? Math.max(120, Math.round(resultWidth / sourceRatio))
: anchor.height;
const resultNode = createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: resultHeight,
@ -4368,26 +4377,34 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
...(resultNode.draftValues || {}),
action: "IMAGE_RESOURCE",
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
aspectRatio: response.data.aspectRatio,
functionKey: "local-edit",
generated: false,
generationProgress: 6,
generationStatus: "pending",
localEditRegions: validRegions,
localEditMode: false,
localEditRegions: [],
mediaType: "image",
mediaUrl: "",
nodeType: "image",
params: {
annotatedReferenceUrl: response.data.annotatedReferenceUrl,
aspectRatio: response.data.aspectRatio,
mode: "local-edit",
prompt: response.data.prompt,
regions: validRegions,
sourceNodeId: anchor.id,
sourceHeight: response.data.sourceHeight,
sourceUrl,
sourceWidth: response.data.sourceWidth,
taskId: response.data.taskId,
},
pendingLabel: "局部编辑中",
prompt: response.data.prompt,
sourceNodeId: anchor.id,
sourceLocalEditRegions: validRegions,
sourceNaturalHeight: response.data.sourceHeight,
sourceNaturalWidth: response.data.sourceWidth,
sourceUrl,
toolbarOption: "局部编辑",
url: [],

View File

@ -8,7 +8,7 @@ import {
OrbitControls,
useGLTF,
} from "@react-three/drei";
import { Canvas, useThree } from "@react-three/fiber";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
@ -57,6 +57,7 @@ type Model3DViewerProps = {
emptyStateTitle?: string;
settings: Model3DViewerSettings;
modelRotation?: [number, number, number];
showMovementControls?: boolean;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
onStatsChange?: (stats: Model3DStats) => void;
@ -64,6 +65,34 @@ type Model3DViewerProps = {
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
};
type MovementCommand = "forward" | "backward" | "left" | "right" | "up" | "down";
type MovementState = Record<MovementCommand, boolean>;
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
backward: false,
left: false,
right: false,
up: false,
down: false,
};
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
ArrowUp: "forward",
KeyS: "backward",
ArrowDown: "backward",
KeyA: "left",
ArrowLeft: "left",
KeyD: "right",
ArrowRight: "right",
KeyQ: "up",
PageUp: "up",
KeyE: "down",
PageDown: "down",
};
const CAMERA_PRESET_POSITIONS: Record<
Model3DCameraPreset,
[number, number, number]
@ -141,6 +170,19 @@ function isMesh(node: Object3D): node is Mesh {
return (node as Mesh).isMesh === true;
}
function createMovementState(): MovementState {
return { ...EMPTY_MOVEMENT_STATE };
}
function isEditableKeyboardTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const editableSelector = "input, textarea, select, [contenteditable='true'], [role='textbox']";
if (target.matches(editableSelector)) return true;
return Boolean(target.closest(editableSelector));
}
function collectSceneMeta(scene: Object3D): {
layers: Model3DLayer[];
stats: Model3DStats;
@ -316,6 +358,161 @@ function ViewerCaptureBridge({
return null;
}
function ModelMovementControls({
enabled,
movementStateRef,
orbitControlsRef,
}: {
enabled: boolean;
movementStateRef: React.MutableRefObject<MovementState>;
orbitControlsRef: React.MutableRefObject<any>;
}) {
const { camera } = useThree();
const directionRef = useRef(new THREE.Vector3());
const rightRef = useRef(new THREE.Vector3());
const upRef = useRef(new THREE.Vector3());
const moveRef = useRef(new THREE.Vector3());
useEffect(() => {
if (!enabled) {
movementStateRef.current = createMovementState();
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditableKeyboardTarget(event.target)) {
movementStateRef.current = createMovementState();
return;
}
event.preventDefault();
movementStateRef.current[movement] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
const movement = KEY_TO_MOVEMENT[event.code];
if (!movement) return;
event.preventDefault();
movementStateRef.current[movement] = false;
};
const handleBlur = () => {
movementStateRef.current = createMovementState();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, [enabled, movementStateRef]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = movementStateRef.current;
if (!Object.values(pressed).some(Boolean)) return;
camera.getWorldDirection(directionRef.current);
directionRef.current.setY(0);
if (directionRef.current.lengthSq() < 0.0001) {
directionRef.current.set(0, 0, -1);
}
directionRef.current.normalize();
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
upRef.current.copy(camera.up).normalize();
moveRef.current.set(0, 0, 0);
if (pressed.forward) moveRef.current.add(directionRef.current);
if (pressed.backward) moveRef.current.addScaledVector(directionRef.current, -1);
if (pressed.left) moveRef.current.addScaledVector(rightRef.current, -1);
if (pressed.right) moveRef.current.add(rightRef.current);
if (pressed.up) moveRef.current.add(upRef.current);
if (pressed.down) moveRef.current.addScaledVector(upRef.current, -1);
if (moveRef.current.lengthSq() === 0) return;
const distance = 2.6 * delta;
moveRef.current.normalize().multiplyScalar(distance);
camera.position.add(moveRef.current);
if (orbitControlsRef.current?.target) {
orbitControlsRef.current.target.add(moveRef.current);
orbitControlsRef.current.update?.();
}
});
return null;
}
function ModelMovementPad({
movementStateRef,
}: {
movementStateRef: React.MutableRefObject<MovementState>;
}) {
const setPressed = React.useCallback(
(command: MovementCommand, pressed: boolean) => {
movementStateRef.current[command] = pressed;
},
[movementStateRef]
);
const clearMovement = React.useCallback(() => {
movementStateRef.current = createMovementState();
}, [movementStateRef]);
const buttonClass =
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
const buttonProps = (command: MovementCommand) => ({
"aria-label": command,
className: buttonClass,
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
setPressed(command, true);
},
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setPressed(command, false);
},
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
clearMovement();
},
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
setPressed(command, false);
},
type: "button" as const,
});
return (
<div
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-20 flex items-end gap-2"
data-canvas-no-drag="true"
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
<span />
<button {...buttonProps("forward")}>W</button>
<span />
<button {...buttonProps("left")}>A</button>
<button {...buttonProps("backward")}>S</button>
<button {...buttonProps("right")}>D</button>
</div>
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/26 p-1.5 backdrop-blur-md">
<button {...buttonProps("up")}>Q</button>
<button {...buttonProps("down")}>E</button>
</div>
</div>
);
}
function isGlbOrGltf(url?: string | null) {
const value = String(url || "").toLowerCase();
return /\.(gltf|glb)(\?|#|$)/.test(value);
@ -359,6 +556,7 @@ export function Model3DViewer({
emptyStateTitle = "准备生成 3D 资产",
settings,
modelRotation,
showMovementControls = false,
layerVisibility,
onLayersChange,
onStatsChange,
@ -366,6 +564,7 @@ export function Model3DViewer({
onCaptureReady,
}: Model3DViewerProps) {
const controlsRef = useRef<any>(null);
const movementStateRef = useRef<MovementState>(createMovementState());
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
@ -414,93 +613,101 @@ export function Model3DViewer({
</div>
}
>
<Canvas
camera={{
position:
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
CAMERA_PRESET_POSITIONS.iso,
fov: 45,
}}
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
gl={{ preserveDrawingBuffer: true }}
onCreated={({ gl }) => {
gl.outputColorSpace = THREE.SRGBColorSpace;
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = 1;
}}
>
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
<CameraPresetController
controlsRef={controlsRef}
preset={settings.cameraPreset}
resetToken={resetViewToken}
/>
<ambientLight intensity={0.5 * settings.lightIntensity} />
<directionalLight
intensity={1.2 * settings.lightIntensity}
position={[5, 8, 5]}
shadow-bias={-0.0001}
/>
{settings.showGrid ? (
<Grid
args={[20, 20]}
cellColor="#3f3f46"
cellSize={0.5}
fadeDistance={30}
fadeStrength={1}
position={[0, -0.01, 0]}
sectionColor="#52525b"
sectionSize={2}
/>
) : null}
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#27272a" wireframe />
</mesh>
}
>
<group position={[0, 0, 0]}>
<ModelScene
layerVisibility={layerVisibility}
modelRotation={modelRotation}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
url={resolvedModelUrl}
/>
<ContactShadows
blur={2}
color="#000000"
far={4}
opacity={0.4}
resolution={256}
scale={10}
/>
</group>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls
autoRotate={settings.autoRotate}
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom
makeDefault
maxPolarAngle={Math.PI / 1.75}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
<div className="relative h-full w-full overflow-hidden">
<Canvas
camera={{
position:
CAMERA_PRESET_POSITIONS[settings.cameraPreset] ||
CAMERA_PRESET_POSITIONS.iso,
fov: 45,
}}
minPolarAngle={0}
ref={controlsRef}
/>
</Canvas>
className="bg-gradient-to-b from-zinc-900 to-zinc-950"
gl={{ preserveDrawingBuffer: true }}
onCreated={({ gl }) => {
gl.outputColorSpace = THREE.SRGBColorSpace;
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.toneMappingExposure = 1;
}}
>
<ViewerCaptureBridge onCaptureReady={onCaptureReady} />
<CameraPresetController
controlsRef={controlsRef}
preset={settings.cameraPreset}
resetToken={resetViewToken}
/>
<ModelMovementControls
enabled={showMovementControls}
movementStateRef={movementStateRef}
orbitControlsRef={controlsRef}
/>
<ambientLight intensity={0.5 * settings.lightIntensity} />
<directionalLight
intensity={1.2 * settings.lightIntensity}
position={[5, 8, 5]}
shadow-bias={-0.0001}
/>
{settings.showGrid ? (
<Grid
args={[20, 20]}
cellColor="#3f3f46"
cellSize={0.5}
fadeDistance={30}
fadeStrength={1}
position={[0, -0.01, 0]}
sectionColor="#52525b"
sectionSize={2}
/>
) : null}
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#27272a" wireframe />
</mesh>
}
>
<group position={[0, 0, 0]}>
<ModelScene
layerVisibility={layerVisibility}
modelRotation={modelRotation}
onLayersChange={onLayersChange}
onStatsChange={onStatsChange}
settings={settings}
url={resolvedModelUrl}
/>
<ContactShadows
blur={2}
color="#000000"
far={4}
opacity={0.4}
resolution={256}
scale={10}
/>
</group>
<Environment preset={settings.envPreset} />
</Suspense>
<OrbitControls
autoRotate={settings.autoRotate}
dampingFactor={0.08}
enableDamping
enablePan={false}
enableRotate
enableZoom
makeDefault
maxPolarAngle={Math.PI / 1.75}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE,
}}
minPolarAngle={0}
ref={controlsRef}
/>
</Canvas>
{showMovementControls ? <ModelMovementPad movementStateRef={movementStateRef} /> : null}
</div>
</ViewerErrorBoundary>
);
}

View File

@ -3813,7 +3813,8 @@ export function ImageNode({
metadata: {
editMode: "local-edit",
sourceNodeId: node.id,
localEditRegions: validRegions,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
connections: [node.id],
@ -3844,7 +3845,21 @@ export function ImageNode({
}
const taskId = submitResult.data.taskId;
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
const localEditSourceRatio =
localEditSourceWidth > 0 && localEditSourceHeight > 0
? localEditSourceWidth / localEditSourceHeight
: 0;
const localEditResultSize =
localEditSourceRatio > 0
? {
width: node.size.width,
height: Math.max(80, Math.round(node.size.width / localEditSourceRatio)),
}
: node.size;
updateNode(newNodeId, {
size: localEditResultSize,
data: {
src: "",
status: "generating",
@ -3854,9 +3869,13 @@ export function ImageNode({
metadata: {
editMode: "local-edit",
sourceNodeId: node.id,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditRegions: validRegions,
localEditMode: false,
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
@ -3889,9 +3908,14 @@ export function ImageNode({
metadata: {
editMode: "local-edit",
sourceNodeId: node.id,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditRegions: validRegions,
localEditMode: false,
localEditRegions: [],
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
@ -3920,7 +3944,8 @@ export function ImageNode({
editMode: "local-edit",
sourceNodeId: node.id,
error: message,
localEditRegions: validRegions,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
});
@ -6671,7 +6696,7 @@ export function ImageNode({
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "completed",
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: false,
hotspots: [],
semanticsMetadata:

View File

@ -66,6 +66,7 @@ const VIEW_META: Array<{
toolbarKey: CanvasToolbarItemKey;
}> = [
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
];
@ -76,52 +77,40 @@ const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360;
function resolveAvailableViews(data: World3DNodeData) {
const views: World3DViewMode[] = [];
const hasSplat = isArchivedWorldLabsAssetUrl(data.splatUrl);
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
if (data.imageUrl || data.previewImage) views.push("image");
if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
if (views.length === 0 && isArchivedWorldLabsAssetUrl(data.splatUrl)) {
if (hasSplat) {
views.push("splat");
} else if (hasMesh) {
views.push("mesh");
}
return views;
}
function resolveActiveView(data: World3DNodeData) {
const availableViews = resolveAvailableViews(data);
const hasSplat = availableViews.includes("splat");
if (
data.activeView &&
data.activeView !== "splat" &&
availableViews.includes(data.activeView)
availableViews.includes(data.activeView) &&
!(data.activeView === "mesh" && hasSplat)
) {
return data.activeView;
}
if (hasSplat) {
return "splat";
}
if (availableViews.includes("mesh")) {
return "mesh";
}
return availableViews[0] || "image";
}
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
function inferModelDownloadTarget(data: World3DNodeData) {
const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
if (activeView === "splat" && splatUrl) {
return { url: splatUrl, filename: "world.spz" };
}
if (activeView === "mesh" && meshUrl) {
return { url: meshUrl, filename: "world.glb" };
}
if (splatUrl) {
return { url: splatUrl, filename: "world.spz" };
}
if (meshUrl) {
return { url: meshUrl, filename: "world.glb" };
}
if (data.imageUrl) {
return { url: data.imageUrl, filename: "world.jpg" };
}
if (data.worldUrl) {
return { url: data.worldUrl, filename: "world.url" };
}
return null;
return meshUrl ? { url: meshUrl, filename: "world.glb" } : null;
}
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
@ -261,7 +250,7 @@ export function World3DNode({
[data.modelVersion, publicModelAliasConfig.worldModels],
);
const primaryUrl = resolvePrimaryUrl(data, activeView);
const downloadTarget = inferDownloadTarget(data, activeView);
const modelDownloadTarget = inferModelDownloadTarget(data);
const hotspotCount = data.hotspots?.length || 0;
const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76),
@ -525,7 +514,7 @@ export function World3DNode({
prompt: promptText,
modelVersion,
status: "completed",
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : "image",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: data.firstPerson ?? false,
hotspots: data.hotspots || [],
semanticsMetadata:
@ -670,7 +659,14 @@ export function World3DNode({
splatUrl: splatUrl || renderableSplatUrl,
previewImage: previewImageUrl || imageUrl || data.previewImage,
status: "completed",
activeView: meshUrl ? "mesh" : imageUrl ? "image" : splatUrl ? "splat" : data.activeView,
activeView:
splatUrl || renderableSplatUrl
? "splat"
: meshUrl || renderableMeshUrl
? "mesh"
: imageUrl
? "image"
: data.activeView,
firstPerson: data.firstPerson ?? false,
semanticsMetadata:
assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object"
@ -783,15 +779,15 @@ export function World3DNode({
}
};
const handleDownload = () => {
if (!downloadTarget) {
toast.error("暂无可下载的世界资产");
const handleDownloadModel = () => {
if (!modelDownloadTarget) {
toast.error("暂无可下载的 GLB 模型");
return;
}
const link = document.createElement("a");
link.href = downloadTarget.url;
link.download = downloadTarget.filename;
link.href = modelDownloadTarget.url;
link.download = modelDownloadTarget.filename;
link.click();
};
@ -952,6 +948,10 @@ export function World3DNode({
splatUrl={renderableSplatUrl}
worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady}
showMovementControls={
(activeView === "splat" || activeView === "mesh") &&
(isSelected || isHovered)
}
/>
</div>
@ -1041,6 +1041,25 @@ export function World3DNode({
</Button>
</div>
) : null}
{modelDownloadTarget ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleDownloadModel}
size="sm"
title="下载模型"
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-2.5 w-2.5"
fallback={Download}
item={world3dDownloadItem}
/>
</Button>
</div>
) : null}
</div>
) : null}
@ -1085,14 +1104,16 @@ export function World3DNode({
{world3dOpenImageItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleDownload}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={world3dDownloadItem}
/>
{world3dDownloadItem.label}
</DropdownMenuItem>
{modelDownloadTarget ? (
<DropdownMenuItem onClick={handleDownloadModel}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={world3dDownloadItem}
/>
{world3dDownloadItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleCopy}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"

View File

@ -82,6 +82,7 @@ type World3DViewerProps = {
className?: string;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
showMovementControls?: boolean;
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
@ -173,8 +174,8 @@ const SPLAT_FAST_LOAD_SKIP_REVEAL_MS = 1200;
const SPARK_INLINE_LOD_BUILD_THRESHOLD = 180_000;
const SPARK_INLINE_LOD_SPLAT_SCALE = 0.78;
const SPARK_INLINE_LOD_RENDER_SCALE = 1.8;
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.6;
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 2.4;
const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82;
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6;
const SPARK_MIN_SORT_INTERVAL_MS = 24;
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
@ -744,10 +745,12 @@ function createMovementState(): MovementState {
function CameraMovementControls({
enabled,
firstPerson,
movementStateRef,
orbitControlsRef,
}: {
enabled: boolean;
firstPerson: boolean;
movementStateRef?: React.MutableRefObject<MovementState>;
orbitControlsRef: React.MutableRefObject<any>;
}) {
const { camera } = useThree();
@ -760,6 +763,9 @@ function CameraMovementControls({
useEffect(() => {
if (!enabled) {
pressedKeysRef.current = createMovementState();
if (movementStateRef) {
movementStateRef.current = createMovementState();
}
return;
}
@ -799,16 +805,19 @@ function CameraMovementControls({
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("focusin", handleFocusIn);
};
}, [enabled]);
}, [enabled, movementStateRef]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = pressedKeysRef.current;
const hasMovement = Object.values(pressed).some(Boolean);
const padPressed = movementStateRef?.current;
const hasMovement =
Object.values(pressed).some(Boolean) ||
Boolean(padPressed && Object.values(padPressed).some(Boolean));
if (!hasMovement) return;
const moveSpeed = 3.8;
const moveSpeed = firstPerson ? 3.8 : 2.8;
const distance = moveSpeed * delta;
camera.getWorldDirection(directionRef.current);
@ -818,22 +827,22 @@ function CameraMovementControls({
moveRef.current.set(0, 0, 0);
if (pressed.forward) {
if (pressed.forward || padPressed?.forward) {
moveRef.current.add(directionRef.current);
}
if (pressed.backward) {
if (pressed.backward || padPressed?.backward) {
moveRef.current.addScaledVector(directionRef.current, -1);
}
if (pressed.left) {
if (pressed.left || padPressed?.left) {
moveRef.current.addScaledVector(rightRef.current, -1);
}
if (pressed.right) {
if (pressed.right || padPressed?.right) {
moveRef.current.add(rightRef.current);
}
if (pressed.up) {
if (pressed.up || padPressed?.up) {
moveRef.current.add(upRef.current);
}
if (pressed.down) {
if (pressed.down || padPressed?.down) {
moveRef.current.addScaledVector(upRef.current, -1);
}
@ -851,6 +860,73 @@ function CameraMovementControls({
return null;
}
function WorldMovementPad({
movementStateRef,
}: {
movementStateRef: React.MutableRefObject<MovementState>;
}) {
const setPressed = React.useCallback(
(command: MovementCommand, pressed: boolean) => {
movementStateRef.current[command] = pressed;
},
[movementStateRef]
);
const clearMovement = React.useCallback(() => {
movementStateRef.current = createMovementState();
}, [movementStateRef]);
const buttonClass =
"flex h-8 w-8 select-none items-center justify-center rounded-[8px] border border-white/15 bg-black/60 text-[12px] font-bold text-white shadow-[0_8px_22px_rgba(0,0,0,0.28)] backdrop-blur-md transition hover:bg-black/75 active:scale-95";
const buttonProps = (command: MovementCommand) => ({
"aria-label": command,
className: buttonClass,
onPointerDown: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
setPressed(command, true);
},
onPointerUp: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setPressed(command, false);
},
onPointerCancel: (event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
clearMovement();
},
onPointerLeave: (event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
setPressed(command, false);
},
type: "button" as const,
});
return (
<div
className="nodrag nopan nowheel pointer-events-auto absolute bottom-3 left-3 z-30 flex items-end gap-2"
data-canvas-no-drag="true"
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="grid grid-cols-3 gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
<span />
<button {...buttonProps("forward")}>W</button>
<span />
<button {...buttonProps("left")}>A</button>
<button {...buttonProps("backward")}>S</button>
<button {...buttonProps("right")}>D</button>
</div>
<div className="grid gap-1 rounded-[12px] border border-white/10 bg-black/28 p-1.5 backdrop-blur-md">
<button {...buttonProps("up")}>Q</button>
<button {...buttonProps("down")}>E</button>
</div>
</div>
);
}
function FirstPersonLookControls({
enabled,
resetToken,
@ -983,6 +1059,8 @@ function WorldSplatScene({
onRevealComplete,
resetViewToken,
showInfoOverlay = true,
movementStateRef,
movementEnabled = false,
}: {
splatUrl: string;
revealPhase?: SplatLoadingPhase;
@ -997,6 +1075,8 @@ function WorldSplatScene({
onRevealComplete?: () => void;
resetViewToken?: number;
showInfoOverlay?: boolean;
movementStateRef?: React.MutableRefObject<MovementState>;
movementEnabled?: boolean;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
@ -1263,8 +1343,9 @@ function WorldSplatScene({
/>
))}
<CameraMovementControls
enabled={interactionMode === "navigate" && firstPerson}
enabled={movementEnabled && interactionMode === "navigate"}
firstPerson={firstPerson}
movementStateRef={movementStateRef}
orbitControlsRef={orbitControlsRef}
/>
<FirstPersonLookControls
@ -1392,6 +1473,7 @@ function WorldSplatViewer({
minimalFrame = false,
onCaptureReady,
showInfoOverlay = true,
showMovementControls = false,
}: {
splatUrl?: string | null;
worldUrl?: string | null;
@ -1406,11 +1488,13 @@ function WorldSplatViewer({
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
showInfoOverlay?: boolean;
showMovementControls?: boolean;
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
const loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
loadStartedAtRef.current = performance.now();
@ -1418,6 +1502,12 @@ function WorldSplatViewer({
setOverlayPhase("loading");
}, [resolvedSplatUrl]);
useEffect(() => {
if (!showMovementControls) {
movementStateRef.current = createMovementState();
}
}, [showMovementControls]);
useEffect(() => {
if (loadState === "ready") {
const elapsed = performance.now() - loadStartedAtRef.current;
@ -1472,10 +1562,10 @@ function WorldSplatViewer({
<Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }}
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
dpr={minimalFrame ? [0.85, 1] : [1, 1]}
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
frameloop="always"
gl={{
antialias: false,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: Boolean(onCaptureReady),
}}
@ -1495,8 +1585,13 @@ function WorldSplatViewer({
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
splatUrl={resolvedSplatUrl}
movementStateRef={movementStateRef}
movementEnabled={showMovementControls}
/>
</Canvas>
{showMovementControls && loadState === "ready" ? (
<WorldMovementPad movementStateRef={movementStateRef} />
) : null}
{overlayPhase !== "hidden" ? (
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
) : null}
@ -1526,6 +1621,7 @@ export function World3DViewer({
className,
resetViewToken,
onCaptureReady,
showMovementControls = false,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
@ -1548,6 +1644,7 @@ export function World3DViewer({
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={meshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
</div>
);
@ -1625,6 +1722,7 @@ export function World3DViewer({
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={!minimalFrame}
showMovementControls={showMovementControls}
splatUrl={splatUrl}
worldUrl={worldUrl}
/>

View File

@ -22,6 +22,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { imageGenerationService } from "@/services/canvas";
import { cn, fetcher } from "@/lib/utils";
import type {
@ -142,7 +148,7 @@ type StyleProWorkspaceProps = {
const STYLE_PRO_HISTORY_LIMIT = 4;
const STYLE_PRO_TASK_POLL_INTERVAL_MS = 1600;
const STYLE_PRO_TASK_TIMEOUT_MS = 4 * 60 * 1000;
const STYLE_PRO_TASK_TIMEOUT_MS = 10 * 60 * 1000;
type StyleProTaskState = {
taskId: string;
@ -645,10 +651,12 @@ function StyleProAccountActions() {
function ImagePreview({
alt,
className,
fit = "contain",
imageUrl,
}: {
alt: string;
className?: string;
fit?: "contain" | "cover";
imageUrl?: string | null;
}) {
if (!imageUrl) {
@ -667,7 +675,11 @@ function ImagePreview({
return (
<img
alt={alt}
className={cn("h-full w-full rounded-lg object-contain", className)}
className={cn(
"h-full w-full rounded-lg",
fit === "cover" ? "object-cover" : "object-contain",
className
)}
draggable={false}
src={imageUrl}
/>
@ -889,6 +901,7 @@ function BeforeAfterSlider({
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
fit="cover"
imageUrl={afterUrl}
/>
<div
@ -898,6 +911,7 @@ function BeforeAfterSlider({
<ImagePreview
alt="生成前"
className="h-full w-full"
fit="cover"
imageUrl={beforeUrl}
/>
</div>
@ -1371,7 +1385,15 @@ export function StyleProWorkspace({
].slice(0, 4);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
try {
await persistState({ sourceImageUrl, history: nextHistory });
} catch (persistError) {
console.error(
"[Style Pro] Failed to persist generated result:",
persistError
);
toast.warning("结果已生成,历史记录稍后同步");
}
setGenerationTaskState(null);
toast.success("风格化 PRO 生成完成");
} catch (error) {
@ -1711,38 +1733,64 @@ export function StyleProWorkspace({
{generationMode === "style" ? (
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
<span className="text-black/45">模型</span>
<select
aria-label="选择生图模型"
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
disabled={
modelsLoading ||
selectableModelOptions.length === 0 ||
generating
}
onChange={(event) =>
setSelectedModelId(event.target.value)
}
value={selectedModel?.id || selectedModelId}
>
{modelOptions.length > 0 ? (
modelOptions.map((model) => (
<option
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
{model.displayName}
{model.availabilityLabel
? `(${model.availabilityLabel})`
: ""}
</option>
))
) : (
<option value="">
{modelsLoading ? "加载模型" : "暂无模型"}
</option>
)}
</select>
{modelOptions.length > 0 ? (
<Select
disabled={
modelsLoading ||
selectableModelOptions.length === 0 ||
generating
}
onValueChange={setSelectedModelId}
value={selectedModel?.id || selectedModelId}
>
<SelectTrigger
aria-label="选择生图模型"
className="h-7 w-[190px] rounded-[10px] border-0 bg-transparent px-1 py-0 text-[12px] font-semibold text-black/75 shadow-none ring-offset-0 focus:ring-0 focus:ring-offset-0 disabled:opacity-60 sm:w-[230px]"
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedModel?.displayName || "选择模型"}
</span>
</SelectTrigger>
<SelectContent className="z-[50000] max-h-[360px] min-w-[340px] rounded-2xl border-black/10 bg-white p-1.5 text-black shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
{modelOptions.map((model) => (
<SelectItem
className="min-h-10 rounded-xl py-2 pl-8 pr-3 text-[13px] focus:bg-black/[0.04]"
disabled={model.selectable === false}
key={model.id}
title={model.disabledReason || model.displayName}
value={model.id}
>
<div className="flex w-full min-w-0 items-center gap-2">
{model.avatarUrl ? (
<img
alt=""
className="h-5 w-5 shrink-0 rounded-full object-cover"
src={model.avatarUrl}
/>
) : null}
<span className="min-w-0 flex-1 whitespace-normal break-words font-semibold leading-snug text-black/80">
{model.displayName}
</span>
{model.availabilityLabel ? (
<span className="shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-600">
{model.availabilityLabel}
</span>
) : null}
{model.creditDiscountLabel ? (
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
{model.creditDiscountLabel}
</span>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="h-6 max-w-[210px] truncate bg-transparent font-semibold text-black/40">
{modelsLoading ? "加载模型" : "暂无模型"}
</span>
)}
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
{currentCostLabel}
</span>

View File

@ -502,8 +502,8 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
{
key: "world3dDownload",
group: "节点工具栏",
label: "下载当前资产",
description: "下载 3D 世界当前资产",
label: "下载模型",
description: "下载 3D 世界 GLB 模型资源",
},
{
key: "world3dCopy",

View File

@ -66,6 +66,21 @@ const VIDEO_MODEL_KEYWORDS = [
"i2v",
];
const EXPLICIT_IMAGE_MODEL_KEYWORDS = [
"/generate/image/",
"gpt-image",
"sora_image",
"qwen-image",
"seedream",
"nano-banana",
"banana",
"flux",
"z-image",
"image-edit",
"gen4_image",
"gen-4 image",
];
function hasVideoGenerationConfig(value: unknown) {
if (!value || typeof value !== "object") return false;
const config = value as Record<string, unknown>;
@ -81,7 +96,36 @@ function hasVideoGenerationConfig(value: unknown) {
function hasVideoCapabilities(value: unknown) {
if (!value || typeof value !== "object") return false;
const serialized = JSON.stringify(value).toLowerCase();
return serialized.includes("text-to-video") || serialized.includes("image-to-video");
return (
serialized.includes("text-to-video") ||
serialized.includes("image-to-video")
);
}
function isExplicitImageGenerationModel(
entry: StyleProModelEntry | StyleProDisplayModelEntry
) {
const searchable = [
entry.model.modelId,
entry.model.displayName,
entry.model.description,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (
searchable.includes("image-to-video") ||
searchable.includes("text-to-video") ||
searchable.includes("i2v") ||
searchable.includes("t2v")
) {
return false;
}
return EXPLICIT_IMAGE_MODEL_KEYWORDS.some((keyword) =>
searchable.includes(keyword)
);
}
function isVideoLikeModel(
@ -111,7 +155,9 @@ function isVideoLikeModel(
export function isStyleProImageModel(
entry: StyleProModelEntry | StyleProDisplayModelEntry
) {
return Boolean(entry.model.supportsImageGen) && !isVideoLikeModel(entry);
if (!entry.model.supportsImageGen) return false;
if (isExplicitImageGenerationModel(entry)) return true;
return !isVideoLikeModel(entry);
}
export async function getStyleProImageModelEntries(options?: {

View File

@ -320,8 +320,11 @@ export const imageGenerationService = {
}): Promise<
ApiResponse<{
annotatedReferenceUrl: string;
aspectRatio?: string;
prompt: string;
queueStatus?: unknown;
sourceHeight?: number;
sourceWidth?: number;
status?: string;
taskId: string;
}>
@ -338,8 +341,11 @@ export const imageGenerationService = {
success: true,
data: {
annotatedReferenceUrl: data.annotatedReferenceUrl,
aspectRatio: data.aspectRatio,
prompt: data.prompt,
queueStatus: data.queueStatus,
sourceHeight: data.sourceHeight,
sourceWidth: data.sourceWidth,
status: data.status,
taskId: data.taskId,
},