|
|
|
@ -13,6 +13,7 @@ import { |
|
|
|
} from "react"; |
|
|
|
import { createPortal } from "react-dom"; |
|
|
|
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react"; |
|
|
|
import { message, Segmented } from "antd"; |
|
|
|
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore"; |
|
|
|
import { useModelStore } from "@/store/modelStore"; |
|
|
|
import { |
|
|
|
@ -79,6 +80,17 @@ type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio"; |
|
|
|
type DraftField = GenerationConfigField; |
|
|
|
type ComposerDraft = GenerationNodeConfig; |
|
|
|
type VideoStitchLoopCount = 1 | 2 | 3; |
|
|
|
type VideoGenerationSubType = 202 | 203 | 204; |
|
|
|
type ComposerReferenceMaterialType = "image" | "video" | "audio"; |
|
|
|
|
|
|
|
interface ComposerReferenceMaterial { |
|
|
|
id: number; |
|
|
|
type: ComposerReferenceMaterialType; |
|
|
|
url: string; |
|
|
|
name: string; |
|
|
|
duration?: number; |
|
|
|
removableIndex?: number; |
|
|
|
} |
|
|
|
|
|
|
|
interface ComposerContext { |
|
|
|
mode: ComposerMode; |
|
|
|
@ -127,6 +139,9 @@ const COMPOSER_COLLAPSED_WIDTH = 560; |
|
|
|
const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300; |
|
|
|
const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80; |
|
|
|
const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4]; |
|
|
|
const VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE = 202; |
|
|
|
const VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO = 203; |
|
|
|
const VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME = 204; |
|
|
|
|
|
|
|
interface SchemaSelectOption { |
|
|
|
key: string; |
|
|
|
@ -161,6 +176,23 @@ function getSchemaParameterDefault(schema: ModelParameter[] | undefined, name: s |
|
|
|
return param?.default ?? param?.options?.[0]?.value ?? param?.enum?.[0]; |
|
|
|
} |
|
|
|
|
|
|
|
function isVideoGenerationSubType(value: unknown): value is VideoGenerationSubType { |
|
|
|
return ( |
|
|
|
value === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE || |
|
|
|
value === VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO || |
|
|
|
value === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
function videoGenerationSubTypeFromUnknown(value: unknown): VideoGenerationSubType | null { |
|
|
|
const parsed = finiteIntegerFromUnknown(value); |
|
|
|
return isVideoGenerationSubType(parsed) ? parsed : null; |
|
|
|
} |
|
|
|
|
|
|
|
function canUseFirstLastFrameSubType(imageCount: number): boolean { |
|
|
|
return imageCount === 2; |
|
|
|
} |
|
|
|
|
|
|
|
function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] { |
|
|
|
return param.options?.map((option, index) => ({ |
|
|
|
key: String(option.value ?? index), |
|
|
|
@ -543,6 +575,74 @@ function getConnectedVideoDurationSeconds( |
|
|
|
return getNodeVideoDurationSeconds(nodes.find((node) => node.id === videoEdge.source)); |
|
|
|
} |
|
|
|
|
|
|
|
function getConnectedVideoDurationList( |
|
|
|
targetNodeId: string, |
|
|
|
nodes: WorkflowNode[], |
|
|
|
edges: Array<{ source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }> |
|
|
|
): number[] { |
|
|
|
return edges |
|
|
|
.filter((edge) => |
|
|
|
edge.target === targetNodeId && |
|
|
|
(isVideoHandle(edge.targetHandle) || isVideoHandle(edge.sourceHandle)) |
|
|
|
) |
|
|
|
.map((edge) => getNodeVideoDurationSeconds(nodes.find((node) => node.id === edge.source))); |
|
|
|
} |
|
|
|
|
|
|
|
function buildComposerReferenceMaterials( |
|
|
|
images: string[], |
|
|
|
videos: string[], |
|
|
|
audio: string[], |
|
|
|
videoDurations: number[] = [], |
|
|
|
removableImages = false |
|
|
|
): ComposerReferenceMaterial[] { |
|
|
|
const materials: ComposerReferenceMaterial[] = []; |
|
|
|
|
|
|
|
images.forEach((url, index) => { |
|
|
|
const id = materials.length + 1; |
|
|
|
materials.push({ |
|
|
|
id, |
|
|
|
type: "image", |
|
|
|
url, |
|
|
|
name: `@图${id}`, |
|
|
|
...(removableImages ? { removableIndex: index } : {}), |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
videos.forEach((url, index) => { |
|
|
|
const id = materials.length + 1; |
|
|
|
const duration = videoDurations[index]; |
|
|
|
materials.push({ |
|
|
|
id, |
|
|
|
type: "video", |
|
|
|
url, |
|
|
|
name: `@视频${id}`, |
|
|
|
...(duration && duration > 0 ? { duration } : {}), |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
audio.forEach((url) => { |
|
|
|
const id = materials.length + 1; |
|
|
|
materials.push({ |
|
|
|
id, |
|
|
|
type: "audio", |
|
|
|
url, |
|
|
|
name: `@音频${id}`, |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
return materials; |
|
|
|
} |
|
|
|
|
|
|
|
function buildReferenceSubjectListParameter(materials: ComposerReferenceMaterial[]): Array<Record<string, string | number>> { |
|
|
|
return materials.map((material) => ({ |
|
|
|
id: material.id, |
|
|
|
type: material.type, |
|
|
|
url: material.url, |
|
|
|
name: material.name, |
|
|
|
...(material.duration ? { duration: material.duration } : {}), |
|
|
|
})); |
|
|
|
} |
|
|
|
|
|
|
|
function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean { |
|
|
|
return Boolean(model.capabilities?.some((capability) => capabilities.includes(capability))); |
|
|
|
} |
|
|
|
@ -927,6 +1027,11 @@ export function GenerationComposer() { |
|
|
|
return getConnectedVideoDurationSeconds(context.node.id, nodes, edges); |
|
|
|
}, [context, edges, nodes]); |
|
|
|
|
|
|
|
const connectedVideoDurations = useMemo(() => { |
|
|
|
if (context.mode !== "node-edit" || !context.node) return []; |
|
|
|
return getConnectedVideoDurationList(context.node.id, nodes, edges); |
|
|
|
}, [context, edges, nodes]); |
|
|
|
|
|
|
|
const estimatedReferenceImageCount = context.mode === "node-edit" && (connectedInputs?.images.length ?? 0) > 0 |
|
|
|
? connectedInputs?.images.length ?? 0 |
|
|
|
: draft.inputImages.length; |
|
|
|
@ -1265,12 +1370,16 @@ export function GenerationComposer() { |
|
|
|
const hasReferenceImageForSubmit = |
|
|
|
(context.mode === "empty-create" || context.mode === "node-edit") && |
|
|
|
((connectedInputs?.images.length ?? 0) > 0 || draft.inputImages.length > 0); |
|
|
|
const hasReferenceVideoForSubmit = |
|
|
|
context.mode === "node-edit" && (connectedInputs?.videos.length ?? 0) > 0; |
|
|
|
const hasReferenceAudioForSubmit = |
|
|
|
context.mode === "node-edit" && (connectedInputs?.audio.length ?? 0) > 0; |
|
|
|
const submitNodeType = context.mode === "empty-create" |
|
|
|
? inferNodeTypeFromModel(draft.selectedModel) |
|
|
|
: context.nodeType; |
|
|
|
const hasRequiredPromptOrReference = submitNodeType === "generateAudio" |
|
|
|
? promptForSubmit.length > 0 |
|
|
|
: (promptForSubmit.length > 0 || hasReferenceImageForSubmit); |
|
|
|
: (promptForSubmit.length > 0 || hasReferenceImageForSubmit || hasReferenceVideoForSubmit || hasReferenceAudioForSubmit); |
|
|
|
const canCreateFromSelectedImageInput = |
|
|
|
context.mode === "unsupported-node" && |
|
|
|
Boolean(selectedImageInputImage) && |
|
|
|
@ -1298,9 +1407,21 @@ export function GenerationComposer() { |
|
|
|
canCreateFromSelectedImageInput; |
|
|
|
|
|
|
|
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : []; |
|
|
|
const connectedReferenceVideos = context.mode === "node-edit" ? connectedInputs?.videos ?? [] : []; |
|
|
|
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : []; |
|
|
|
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages; |
|
|
|
const canUseFirstLastFrame = canUseFirstLastFrameSubType(referenceImages.length); |
|
|
|
const referenceMaterials = useMemo(() => buildComposerReferenceMaterials( |
|
|
|
referenceImages, |
|
|
|
connectedReferenceVideos, |
|
|
|
connectedReferenceAudio, |
|
|
|
connectedVideoDurations, |
|
|
|
connectedReferenceImages.length === 0 |
|
|
|
), [connectedReferenceAudio, connectedReferenceImages.length, connectedReferenceVideos, connectedVideoDurations, referenceImages]); |
|
|
|
const previewReferenceImage = |
|
|
|
referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null; |
|
|
|
referencePreviewIndex === null ? null : referenceMaterials[referencePreviewIndex]?.type === "image" |
|
|
|
? referenceMaterials[referencePreviewIndex]?.url ?? null |
|
|
|
: null; |
|
|
|
const canEditReferenceImages = |
|
|
|
(context.mode === "empty-create" || context.mode === "node-edit") && |
|
|
|
connectedReferenceImages.length === 0; |
|
|
|
@ -1501,6 +1622,37 @@ export function GenerationComposer() { |
|
|
|
[updateNodeData] |
|
|
|
); |
|
|
|
|
|
|
|
const isVideoSubTypeSelectorVisible = context.mode === "node-edit" && context.nodeType === "generateVideo"; |
|
|
|
const currentVideoSubType = videoGenerationSubTypeFromUnknown(draft.parameters?.subType); |
|
|
|
const selectedVideoSubType = currentVideoSubType ?? VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE; |
|
|
|
const isFirstLastFrameSelected = |
|
|
|
selectedVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && canUseFirstLastFrame; |
|
|
|
const videoSubTypeOptions = useMemo( |
|
|
|
() => [ |
|
|
|
{ |
|
|
|
label: t("composer.videoSubTypeMultiReference"), |
|
|
|
value: VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE, |
|
|
|
}, |
|
|
|
{ |
|
|
|
label: t("composer.videoSubTypeFirstLastFrame"), |
|
|
|
value: VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME, |
|
|
|
disabled: !canUseFirstLastFrame, |
|
|
|
}, |
|
|
|
{ |
|
|
|
label: t("composer.videoSubTypeTextToVideo"), |
|
|
|
value: VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO, |
|
|
|
}, |
|
|
|
], |
|
|
|
[canUseFirstLastFrame, t] |
|
|
|
); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!isVideoSubTypeSelectorVisible) return; |
|
|
|
if (currentVideoSubType !== VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) return; |
|
|
|
if (canUseFirstLastFrame) return; |
|
|
|
updateSchemaDraftParameter("subType", VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE); |
|
|
|
}, [canUseFirstLastFrame, currentVideoSubType, isVideoSubTypeSelectorVisible, updateSchemaDraftParameter]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if ( |
|
|
|
isProcessNode || |
|
|
|
@ -1706,10 +1858,10 @@ export function GenerationComposer() { |
|
|
|
); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) { |
|
|
|
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceMaterials.length) { |
|
|
|
setReferencePreviewIndex(null); |
|
|
|
} |
|
|
|
}, [referenceImages.length, referencePreviewIndex]); |
|
|
|
}, [referenceMaterials.length, referencePreviewIndex]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!previewReferenceImage) return; |
|
|
|
@ -1741,6 +1893,43 @@ export function GenerationComposer() { |
|
|
|
if (!canSubmit) return; |
|
|
|
const fallbackPrompt = buildReferenceOnlyPrompt(); |
|
|
|
const submitPrompt = promptForSubmit || fallbackPrompt; |
|
|
|
const activeDraft = draftRef.current; |
|
|
|
const submitVideoNodeType = context.mode === "empty-create" |
|
|
|
? inferNodeTypeFromModel(activeDraft.selectedModel) |
|
|
|
: context.nodeType; |
|
|
|
const explicitSubmitVideoSubType = videoGenerationSubTypeFromUnknown(activeDraft.parameters?.subType); |
|
|
|
const submitVideoSubType = explicitSubmitVideoSubType ?? VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE; |
|
|
|
const hasSubmitReferenceImage = |
|
|
|
(connectedInputs?.images.length ?? 0) > 0 || activeDraft.inputImages.length > 0; |
|
|
|
const submitReferenceImageCount = connectedInputs?.images.length && connectedInputs.images.length > 0 |
|
|
|
? connectedInputs.images.length |
|
|
|
: activeDraft.inputImages.length; |
|
|
|
const referenceSubjectList = buildReferenceSubjectListParameter(referenceMaterials); |
|
|
|
const shouldWriteVideoSubType = context.mode === "empty-create" || explicitSubmitVideoSubType !== null; |
|
|
|
const videoParameters = submitVideoNodeType === "generateVideo" |
|
|
|
? { |
|
|
|
...activeDraft.parameters, |
|
|
|
...(shouldWriteVideoSubType ? { subType: submitVideoSubType } : {}), |
|
|
|
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}), |
|
|
|
} |
|
|
|
: activeDraft.parameters; |
|
|
|
if ( |
|
|
|
submitVideoNodeType === "generateVideo" && |
|
|
|
explicitSubmitVideoSubType !== null && |
|
|
|
explicitSubmitVideoSubType !== VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO && |
|
|
|
!hasSubmitReferenceImage |
|
|
|
) { |
|
|
|
message.warning(t("composer.videoSubTypeImageRequired")); |
|
|
|
return; |
|
|
|
} |
|
|
|
if ( |
|
|
|
submitVideoNodeType === "generateVideo" && |
|
|
|
explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && |
|
|
|
!canUseFirstLastFrameSubType(submitReferenceImageCount) |
|
|
|
) { |
|
|
|
message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired")); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (context.mode === "unsupported-node" && context.node?.type === "imageInput") { |
|
|
|
const imageData = context.node.data as ImageInputNodeData; |
|
|
|
@ -1767,14 +1956,15 @@ export function GenerationComposer() { |
|
|
|
} |
|
|
|
|
|
|
|
if (context.mode === "empty-create") { |
|
|
|
const nodeType = inferNodeTypeFromModel(draftRef.current.selectedModel); |
|
|
|
const nodeType = submitVideoNodeType; |
|
|
|
if (!nodeType) return; |
|
|
|
|
|
|
|
const nextNodeId = addNode( |
|
|
|
nodeType, |
|
|
|
buildTargetPosition(), |
|
|
|
buildInitialDataForNode(nodeType, { |
|
|
|
...draftRef.current, |
|
|
|
...activeDraft, |
|
|
|
parameters: nodeType === "generateVideo" ? videoParameters : activeDraft.parameters, |
|
|
|
prompt: submitPrompt, |
|
|
|
}) |
|
|
|
); |
|
|
|
@ -1786,6 +1976,18 @@ export function GenerationComposer() { |
|
|
|
} |
|
|
|
|
|
|
|
if (context.mode === "node-edit" && context.node) { |
|
|
|
if (submitVideoNodeType === "generateVideo") { |
|
|
|
const nextDraft = { |
|
|
|
...draftRef.current, |
|
|
|
parameters: videoParameters, |
|
|
|
}; |
|
|
|
const nextDirtyFields = new Set(dirtyFieldsRef.current); |
|
|
|
nextDirtyFields.add("parameters"); |
|
|
|
draftRef.current = nextDraft; |
|
|
|
dirtyFieldsRef.current = nextDirtyFields; |
|
|
|
setDraft(nextDraft); |
|
|
|
setDirtyFields(nextDirtyFields); |
|
|
|
} |
|
|
|
if (!promptForSubmit && !promptReadOnly) { |
|
|
|
const nextDraft = { ...draftRef.current, prompt: submitPrompt }; |
|
|
|
const nextDirtyFields = new Set(dirtyFieldsRef.current); |
|
|
|
@ -1811,12 +2013,15 @@ export function GenerationComposer() { |
|
|
|
buildTargetPosition, |
|
|
|
canSubmit, |
|
|
|
clearDirtyFields, |
|
|
|
connectedInputs, |
|
|
|
context, |
|
|
|
flushCurrentDraft, |
|
|
|
promptForSubmit, |
|
|
|
promptReadOnly, |
|
|
|
referenceMaterials, |
|
|
|
regenerateNode, |
|
|
|
selectSingleNode, |
|
|
|
t, |
|
|
|
]); |
|
|
|
|
|
|
|
const insertCommand = useCallback((value: string) => { |
|
|
|
@ -1995,13 +2200,31 @@ export function GenerationComposer() { |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<> |
|
|
|
<div className="flex items-center gap-2 px-4 pt-4"> |
|
|
|
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3"> |
|
|
|
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span> |
|
|
|
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span> |
|
|
|
</div> |
|
|
|
<div className="px-4 pt-4"> |
|
|
|
{isVideoSubTypeSelectorVisible && ( |
|
|
|
<Segmented |
|
|
|
options={videoSubTypeOptions} |
|
|
|
value={selectedVideoSubType} |
|
|
|
onChange={(value) => { |
|
|
|
const nextSubType = videoGenerationSubTypeFromUnknown(value); |
|
|
|
if (!nextSubType) 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 ? ( |
|
|
|
<div className="mt-2 flex items-start gap-2"> |
|
|
|
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3"> |
|
|
|
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span> |
|
|
|
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span> |
|
|
|
</div> |
|
|
|
|
|
|
|
{isProcessNode ? ( |
|
|
|
isVideoStitchNode ? ( |
|
|
|
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300"> |
|
|
|
<span> |
|
|
|
@ -2024,82 +2247,127 @@ export function GenerationComposer() { |
|
|
|
</div> |
|
|
|
) |
|
|
|
) : ( |
|
|
|
<> |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
onClick={() => referenceImageInputRef.current?.click()} |
|
|
|
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES} |
|
|
|
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")} |
|
|
|
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50" |
|
|
|
> |
|
|
|
<span className="text-base leading-none">+</span> |
|
|
|
<span>{t("composer.reference")}</span> |
|
|
|
</button> |
|
|
|
|
|
|
|
{referenceImages.length > 0 && ( |
|
|
|
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1"> |
|
|
|
{referenceImages.map((referenceImage, imageIndex) => ( |
|
|
|
<div |
|
|
|
key={`${imageIndex}-${referenceImage.slice(0, 24)}`} |
|
|
|
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900" |
|
|
|
> |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })} |
|
|
|
title={t("composer.referenceImage", { index: imageIndex + 1 })} |
|
|
|
onClick={() => setReferencePreviewIndex(imageIndex)} |
|
|
|
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70" |
|
|
|
<div className="flex min-w-0 items-center gap-2"> |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
onClick={() => referenceImageInputRef.current?.click()} |
|
|
|
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES} |
|
|
|
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")} |
|
|
|
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50" |
|
|
|
> |
|
|
|
<span className="text-base leading-none">+</span> |
|
|
|
<span>{t("composer.reference")}</span> |
|
|
|
</button> |
|
|
|
|
|
|
|
{referenceMaterials.length > 0 && ( |
|
|
|
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1"> |
|
|
|
{referenceMaterials.map((material, materialIndex) => ( |
|
|
|
<div |
|
|
|
key={`${material.type}-${material.id}-${material.url.slice(0, 24)}`} |
|
|
|
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900" |
|
|
|
> |
|
|
|
<img |
|
|
|
src={referenceImage} |
|
|
|
alt="" |
|
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105" |
|
|
|
/> |
|
|
|
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20"> |
|
|
|
<svg |
|
|
|
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90" |
|
|
|
viewBox="0 0 24 24" |
|
|
|
fill="none" |
|
|
|
stroke="currentColor" |
|
|
|
strokeWidth="2" |
|
|
|
> |
|
|
|
<path d="M15 3h6v6" /> |
|
|
|
<path d="M21 3l-7 7" /> |
|
|
|
<path d="M9 21H3v-6" /> |
|
|
|
<path d="M3 21l7-7" /> |
|
|
|
</svg> |
|
|
|
</span> |
|
|
|
</button> |
|
|
|
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200"> |
|
|
|
{imageIndex + 1} |
|
|
|
</span> |
|
|
|
{canEditReferenceImages && ( |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })} |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
removeReferenceImage(imageIndex); |
|
|
|
aria-label={material.type === "image" |
|
|
|
? t("composer.referenceImage", { index: material.id }) |
|
|
|
: material.name} |
|
|
|
title={material.type === "image" |
|
|
|
? t("composer.referenceImage", { index: material.id }) |
|
|
|
: material.name} |
|
|
|
onClick={() => { |
|
|
|
if (material.type === "image") setReferencePreviewIndex(materialIndex); |
|
|
|
}} |
|
|
|
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white" |
|
|
|
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70" |
|
|
|
> |
|
|
|
鑴? |
|
|
|
{material.type === "image" ? ( |
|
|
|
<img |
|
|
|
src={material.url} |
|
|
|
alt="" |
|
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105" |
|
|
|
/> |
|
|
|
) : material.type === "video" ? ( |
|
|
|
<video |
|
|
|
src={material.url} |
|
|
|
muted |
|
|
|
playsInline |
|
|
|
preload="metadata" |
|
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-105" |
|
|
|
/> |
|
|
|
) : ( |
|
|
|
<div className="flex h-full w-full items-center justify-center bg-violet-950/80 text-violet-100"> |
|
|
|
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M9 18V5l12-2v13" /> |
|
|
|
<circle cx="6" cy="18" r="3" /> |
|
|
|
<circle cx="18" cy="16" r="3" /> |
|
|
|
</svg> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20"> |
|
|
|
{material.type === "image" ? ( |
|
|
|
<svg |
|
|
|
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90" |
|
|
|
viewBox="0 0 24 24" |
|
|
|
fill="none" |
|
|
|
stroke="currentColor" |
|
|
|
strokeWidth="2" |
|
|
|
> |
|
|
|
<path d="M15 3h6v6" /> |
|
|
|
<path d="M21 3l-7 7" /> |
|
|
|
<path d="M9 21H3v-6" /> |
|
|
|
<path d="M3 21l7-7" /> |
|
|
|
</svg> |
|
|
|
) : material.type === "video" ? ( |
|
|
|
<svg |
|
|
|
className="h-5 w-5 opacity-90" |
|
|
|
viewBox="0 0 24 24" |
|
|
|
fill="currentColor" |
|
|
|
> |
|
|
|
<path d="M8 5v14l11-7z" /> |
|
|
|
</svg> |
|
|
|
) : ( |
|
|
|
<svg className="h-5 w-5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M9 18V5l12-2v13" /> |
|
|
|
<circle cx="6" cy="18" r="3" /> |
|
|
|
<circle cx="18" cy="16" r="3" /> |
|
|
|
</svg> |
|
|
|
)} |
|
|
|
</span> |
|
|
|
</button> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</> |
|
|
|
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200"> |
|
|
|
{isFirstLastFrameSelected && material.type === "image" |
|
|
|
? material.id === 1 |
|
|
|
? t("composer.firstFrame") |
|
|
|
: t("composer.lastFrame") |
|
|
|
: material.id} |
|
|
|
</span> |
|
|
|
{canEditReferenceImages && material.type === "image" && material.removableIndex !== undefined && ( |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
aria-label={t("composer.removeReferenceImage", { index: material.removableIndex + 1 })} |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
removeReferenceImage(material.removableIndex!); |
|
|
|
}} |
|
|
|
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white" |
|
|
|
> |
|
|
|
鑴? |
|
|
|
</button> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-1 text-neutral-500"> |
|
|
|
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}> |
|
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M17 7 7 17" /> |
|
|
|
<path d="M16 17H7V8" /> |
|
|
|
</svg> |
|
|
|
</IconButton> |
|
|
|
<div className="ml-auto flex items-center gap-1 text-neutral-500"> |
|
|
|
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}> |
|
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M17 7 7 17" /> |
|
|
|
<path d="M16 17H7V8" /> |
|
|
|
</svg> |
|
|
|
</IconButton> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
@ -2129,17 +2397,38 @@ export function GenerationComposer() { |
|
|
|
</div> |
|
|
|
) : ( |
|
|
|
<div className="py-1"> |
|
|
|
{referenceImages.length > 0 ? ( |
|
|
|
referenceImages.map((referenceImage, imageIndex) => { |
|
|
|
const referenceLabel = `图${imageIndex + 1}`; |
|
|
|
{referenceMaterials.length > 0 ? ( |
|
|
|
referenceMaterials.map((material) => { |
|
|
|
const referenceLabel = material.name.replace(/^@/, ""); |
|
|
|
return ( |
|
|
|
<button |
|
|
|
key={`${imageIndex}-${referenceImage.slice(0, 24)}`} |
|
|
|
key={`${material.type}-${material.id}-${material.url.slice(0, 24)}`} |
|
|
|
type="button" |
|
|
|
onClick={() => insertReference(`@${referenceLabel} `)} |
|
|
|
onClick={() => insertReference(`${material.name} `)} |
|
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
> |
|
|
|
<img src={referenceImage} alt="" className="h-8 w-8 rounded object-cover" /> |
|
|
|
<span className="relative h-8 w-8 shrink-0 overflow-hidden rounded bg-neutral-900"> |
|
|
|
{material.type === "image" ? ( |
|
|
|
<img src={material.url} alt="" className="h-8 w-8 object-cover" /> |
|
|
|
) : material.type === "video" ? ( |
|
|
|
<> |
|
|
|
<video src={material.url} muted playsInline preload="metadata" className="h-8 w-8 object-cover" /> |
|
|
|
<span className="absolute inset-0 flex items-center justify-center bg-black/20 text-white"> |
|
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"> |
|
|
|
<path d="M8 5v14l11-7z" /> |
|
|
|
</svg> |
|
|
|
</span> |
|
|
|
</> |
|
|
|
) : ( |
|
|
|
<span className="flex h-8 w-8 items-center justify-center bg-violet-950/80 text-violet-100"> |
|
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
|
|
|
<path d="M9 18V5l12-2v13" /> |
|
|
|
<circle cx="6" cy="18" r="3" /> |
|
|
|
<circle cx="18" cy="16" r="3" /> |
|
|
|
</svg> |
|
|
|
</span> |
|
|
|
)} |
|
|
|
</span> |
|
|
|
<span>{referenceLabel}</span> |
|
|
|
</button> |
|
|
|
); |
|
|
|
|