fix(web): Web 检查 - 格式化拆分模块并修正源码断言
拆分出的前端模块未按 Prettier 格式化导致 CI 格式检查失败;源码断言改为忽略空白差异,导演台引导组件恢复别名导入。同时移除误提交的分析草稿、pnpm 锁文件和调试测试。
This commit is contained in:
@@ -19,7 +19,6 @@ export const ART_CRITIQUE_STAGE_PROGRESS: Record<ArtCritiquePipelineStage, numbe
|
||||
failed: 100,
|
||||
};
|
||||
|
||||
|
||||
export function ArtCritiqueProgress({ stage, theme }: { stage: ArtCritiquePipelineStage; theme: CanvasTheme }) {
|
||||
const percent = ART_CRITIQUE_STAGE_PROGRESS[stage];
|
||||
const detail = stage === "reviewing" ? "场景路由与多个 Reviewer 正在并行检查画面" : stage === "verifying" ? "正在并行复核问题并生成 AI 修改提示词" : "正在按阶段生成批改报告";
|
||||
|
||||
@@ -9,7 +9,23 @@ import { createPortal } from "react-dom";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
export const TOOL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brush, Camera, Clapperboard, Clock, Contrast, FastForward, Globe2, Grid2x2, Grid3x3, Package, Palette, PersonStanding, Rewind, ScanFace, SlidersHorizontal, Sparkles, Sun,
|
||||
Brush,
|
||||
Camera,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
Contrast,
|
||||
FastForward,
|
||||
Globe2,
|
||||
Grid2x2,
|
||||
Grid3x3,
|
||||
Package,
|
||||
Palette,
|
||||
PersonStanding,
|
||||
Rewind,
|
||||
ScanFace,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Sun,
|
||||
};
|
||||
|
||||
export function toolIconSvg(iconName: string): string {
|
||||
@@ -71,7 +87,9 @@ export function InlineReferencePreview({ reference, onClose }: { reference: Canv
|
||||
<div className="fixed inset-0 z-[var(--z-dialog-popover)] grid place-items-center bg-black/80 p-6" role="dialog" aria-label={`预览${reference.label}`} onClick={onClose}>
|
||||
<div className="relative max-h-[92vh] max-w-[92vw]" onClick={(event) => event.stopPropagation()}>
|
||||
<img src={url} alt={reference.label} className="max-h-[88vh] max-w-[88vw] rounded-xl object-contain shadow-2xl" />
|
||||
<button type="button" className="absolute -right-3 -top-3 rounded-full bg-black/75 p-2 text-white shadow-lg" onClick={onClose} aria-label="关闭图片预览">×</button>
|
||||
<button type="button" className="absolute -right-3 -top-3 rounded-full bg-black/75 p-2 text-white shadow-lg" onClick={onClose} aria-label="关闭图片预览">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -10,7 +10,19 @@ import { CanvasNodeType } from "@/types/canvas";
|
||||
import { primeVideoPreviewFrame } from "./canvas-mention-chips";
|
||||
import { pointForOffset } from "./canvas-mention-editable";
|
||||
|
||||
export function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
|
||||
export function MentionMenu({
|
||||
anchor,
|
||||
connectedReferences,
|
||||
assetReferences,
|
||||
filteredReferences,
|
||||
query,
|
||||
cursorOffset,
|
||||
activeReferenceId,
|
||||
preferredWidth,
|
||||
onQueryChange,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
anchor: HTMLElement;
|
||||
connectedReferences: CanvasResourceReference[];
|
||||
assetReferences: CanvasResourceReference[];
|
||||
@@ -60,11 +72,7 @@ export function MentionMenu({ anchor, connectedReferences, assetReferences, filt
|
||||
.filter((item) => item.count > 0);
|
||||
const connectedNodes = connectedReferences.filter((item) => item.kind !== "skill");
|
||||
const skillReferences = connectedReferences.filter((item) => item.kind === "skill");
|
||||
const visibleReferences = query
|
||||
? filteredReferences
|
||||
: category
|
||||
? assetReferences.filter((item) => item.category === category)
|
||||
: [];
|
||||
const visibleReferences = query ? filteredReferences : category ? assetReferences.filter((item) => item.category === category) : [];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const closeOnOutsidePointer = (event: globalThis.PointerEvent) => {
|
||||
@@ -125,19 +133,28 @@ export function MentionMenu({ anchor, connectedReferences, assetReferences, filt
|
||||
<>
|
||||
{connectedNodes.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4><span>画布节点</span><small>{connectedNodes.length}</small></h4>
|
||||
<h4>
|
||||
<span>画布节点</span>
|
||||
<small>{connectedNodes.length}</small>
|
||||
</h4>
|
||||
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{skillReferences.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4><span>技能库</span><small>{skillReferences.length}</small></h4>
|
||||
<h4>
|
||||
<span>技能库</span>
|
||||
<small>{skillReferences.length}</small>
|
||||
</h4>
|
||||
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{categoryItems.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4><span>素材库</span><small>{assetReferences.length}</small></h4>
|
||||
<h4>
|
||||
<span>素材库</span>
|
||||
<small>{assetReferences.length}</small>
|
||||
</h4>
|
||||
{categoryItems.map((item) => (
|
||||
<button key={item.value} type="button" className="canvas-resource-mention-folder" onClick={() => setCategory(item.value)}>
|
||||
<Folder aria-hidden />
|
||||
@@ -177,10 +194,23 @@ export function MentionReferenceList({ references, activeReferenceId, onSelect }
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="canvas-resource-mention-copy">
|
||||
<span className="canvas-resource-mention-title-row"><strong title={reference.label}>{reference.label}</strong>{reference.kind === "skill" ? <em>技能</em> : null}</span>
|
||||
<span className="canvas-resource-mention-title-row">
|
||||
<strong title={reference.label}>{reference.label}</strong>
|
||||
{reference.kind === "skill" ? <em>技能</em> : null}
|
||||
</span>
|
||||
{reference.kind === "skill" ? (
|
||||
<span className="canvas-resource-mention-meta"><span>{reference.skill?.description || reference.text || "工作流技能"}</span><small>{reference.skill?.version ? `v${reference.skill.version}` : ""}{reference.skill?.fileCount ? ` · ${reference.skill.fileCount} 文件` : ""}</small></span>
|
||||
) : reference.text && reference.text !== reference.title ? <span className="canvas-resource-mention-meta"><span>{reference.text}</span></span> : null}
|
||||
<span className="canvas-resource-mention-meta">
|
||||
<span>{reference.skill?.description || reference.text || "工作流技能"}</span>
|
||||
<small>
|
||||
{reference.skill?.version ? `v${reference.skill.version}` : ""}
|
||||
{reference.skill?.fileCount ? ` · ${reference.skill.fileCount} 文件` : ""}
|
||||
</small>
|
||||
</span>
|
||||
) : reference.text && reference.text !== reference.title ? (
|
||||
<span className="canvas-resource-mention-meta">
|
||||
<span>{reference.text}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
));
|
||||
|
||||
@@ -450,7 +450,18 @@ export function ImageContent({
|
||||
}
|
||||
|
||||
/** A stable visible img is updated only after a queued candidate has loaded and decoded. */
|
||||
export function RetainedCanvasImage({ identity, src, storageKey, fallbackSrc, originalSize, alt, fitToImage, className, loading, theme }: {
|
||||
export function RetainedCanvasImage({
|
||||
identity,
|
||||
src,
|
||||
storageKey,
|
||||
fallbackSrc,
|
||||
originalSize,
|
||||
alt,
|
||||
fitToImage,
|
||||
className,
|
||||
loading,
|
||||
theme,
|
||||
}: {
|
||||
identity: string;
|
||||
src: string;
|
||||
storageKey?: string;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { type AnimationClip } from "three";
|
||||
import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
import { DirectorViewport } from "./director-viewport";
|
||||
import { CanvasDirectorOnboarding } from "./canvas-director-onboarding";
|
||||
import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding";
|
||||
import { DirectorViewportDock } from "./director-viewport-dock";
|
||||
import { DirectorSequencer } from "./director-sequencer";
|
||||
import { App, type MenuProps, Input, Button } from "antd";
|
||||
|
||||
@@ -25,25 +25,83 @@ import { Euler, Quaternion } from "three";
|
||||
import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
|
||||
|
||||
export const poseOptions: Array<{ label: string; value: DirectorPose }> = [
|
||||
{ label: "站立", value: "stand" }, { label: "T 型", value: "t_pose" }, { label: "行走", value: "walk" }, { label: "跑步", value: "run" },
|
||||
{ label: "坐姿", value: "sit" }, { label: "蹲下", value: "squat" }, { label: "单膝跪", value: "kneel_single" }, { label: "双膝跪", value: "kneel_double" },
|
||||
{ label: "叉腰", value: "hands_hips" }, { label: "倚靠", value: "lean" }, { label: "鞠躬", value: "bow" }, { label: "思考", value: "think" },
|
||||
{ label: "格斗", value: "fight" }, { label: "踢球", value: "kick" }, { label: "投掷", value: "throw" }, { label: "推进", value: "push" },
|
||||
{ label: "招手", value: "wave" }, { label: "伸手", value: "reach" }, { label: "抱臂", value: "arms_crossed" }, { label: "看手机", value: "phone" },
|
||||
{ label: "站立", value: "stand" },
|
||||
{ label: "T 型", value: "t_pose" },
|
||||
{ label: "行走", value: "walk" },
|
||||
{ label: "跑步", value: "run" },
|
||||
{ label: "坐姿", value: "sit" },
|
||||
{ label: "蹲下", value: "squat" },
|
||||
{ label: "单膝跪", value: "kneel_single" },
|
||||
{ label: "双膝跪", value: "kneel_double" },
|
||||
{ label: "叉腰", value: "hands_hips" },
|
||||
{ label: "倚靠", value: "lean" },
|
||||
{ label: "鞠躬", value: "bow" },
|
||||
{ label: "思考", value: "think" },
|
||||
{ label: "格斗", value: "fight" },
|
||||
{ label: "踢球", value: "kick" },
|
||||
{ label: "投掷", value: "throw" },
|
||||
{ label: "推进", value: "push" },
|
||||
{ label: "招手", value: "wave" },
|
||||
{ label: "伸手", value: "reach" },
|
||||
{ label: "抱臂", value: "arms_crossed" },
|
||||
{ label: "看手机", value: "phone" },
|
||||
];
|
||||
|
||||
export const shotSizeOptions = [{ label: "大远景", value: "extreme_wide" }, { label: "远景", value: "wide" }, { label: "全身景", value: "full" }, { label: "中景", value: "medium" }, { label: "近景", value: "close_up" }, { label: "大特写", value: "extreme_close_up" }];
|
||||
export const shotSizeOptions = [
|
||||
{ label: "大远景", value: "extreme_wide" },
|
||||
{ label: "远景", value: "wide" },
|
||||
{ label: "全身景", value: "full" },
|
||||
{ label: "中景", value: "medium" },
|
||||
{ label: "近景", value: "close_up" },
|
||||
{ label: "大特写", value: "extreme_close_up" },
|
||||
];
|
||||
|
||||
export const cameraMoveOptions = [{ label: "固定", value: "static" }, { label: "推进", value: "push_in" }, { label: "拉远", value: "pull_out" }, { label: "左摇", value: "pan_left" }, { label: "右摇", value: "pan_right" }, { label: "上摇", value: "tilt_up" }, { label: "下摇", value: "tilt_down" }, { label: "左环绕", value: "orbit_left" }, { label: "右环绕", value: "orbit_right" }, { label: "手持", value: "handheld" }];
|
||||
export const cameraMoveOptions = [
|
||||
{ label: "固定", value: "static" },
|
||||
{ label: "推进", value: "push_in" },
|
||||
{ label: "拉远", value: "pull_out" },
|
||||
{ label: "左摇", value: "pan_left" },
|
||||
{ label: "右摇", value: "pan_right" },
|
||||
{ label: "上摇", value: "tilt_up" },
|
||||
{ label: "下摇", value: "tilt_down" },
|
||||
{ label: "左环绕", value: "orbit_left" },
|
||||
{ label: "右环绕", value: "orbit_right" },
|
||||
{ label: "手持", value: "handheld" },
|
||||
];
|
||||
|
||||
|
||||
export function ObjectInspector({ object, rendered, playhead, selectedBone, capabilities, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; capabilities: DirectorModeCapabilities; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => void; onDelete: () => void }) {
|
||||
export function ObjectInspector({
|
||||
object,
|
||||
rendered,
|
||||
playhead,
|
||||
selectedBone,
|
||||
capabilities,
|
||||
onSelectBone,
|
||||
onUpdate,
|
||||
onTransformEdit,
|
||||
onBoneRotationStage,
|
||||
onBoneRotationCommit,
|
||||
onAddKeyframe,
|
||||
onDelete,
|
||||
}: {
|
||||
object: DirectorObject;
|
||||
rendered: DirectorTransform;
|
||||
playhead: number;
|
||||
selectedBone: string | null;
|
||||
capabilities: DirectorModeCapabilities;
|
||||
onSelectBone: (bone: string | null) => void;
|
||||
onUpdate: (patch: Partial<DirectorObject>) => void;
|
||||
onTransformEdit: (transform: DirectorTransform) => void;
|
||||
onBoneRotationStage: (rotation: DirectorQuat) => void;
|
||||
onBoneRotationCommit: () => void;
|
||||
onAddKeyframe: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const motionClips = object.motionClips || [];
|
||||
const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId);
|
||||
const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[];
|
||||
const selectedBoneId = selectedBone as DirectorHumanoidBone | null;
|
||||
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || [0, 0, 0, 1] as DirectorQuat : null;
|
||||
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) });
|
||||
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || ([0, 0, 0, 1] as DirectorQuat) : null;
|
||||
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => (clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip)) });
|
||||
const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
|
||||
const resetSelectedBone = () => {
|
||||
if (!selectedBoneId) return;
|
||||
@@ -51,61 +109,254 @@ export function ObjectInspector({ object, rendered, playhead, selectedBone, capa
|
||||
delete boneOverrides[selectedBoneId];
|
||||
onUpdate({ boneOverrides });
|
||||
};
|
||||
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
|
||||
<TransformFields transform={rendered} onChange={onTransformEdit} />
|
||||
{object.kind === "actor" || object.primitive === "character"
|
||||
? <Field label="角色颜色"><div className="director-actor-colors">{DIRECTOR_ACTOR_COLORS.map((color) => <button key={color} type="button" className={`director-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`} style={{ background: color }} aria-label={`设置颜色 ${color}`} onClick={() => onUpdate({ color })} />)}<ColorPicker value={object.color} size="small" onChange={(_, color) => onUpdate({ color })} /></div></Field>
|
||||
: <Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>}
|
||||
{/*
|
||||
return (
|
||||
<Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
|
||||
<TransformFields transform={rendered} onChange={onTransformEdit} />
|
||||
{object.kind === "actor" || object.primitive === "character" ? (
|
||||
<Field label="角色颜色">
|
||||
<div className="director-actor-colors">
|
||||
{DIRECTOR_ACTOR_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`director-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`}
|
||||
style={{ background: color }}
|
||||
aria-label={`设置颜色 ${color}`}
|
||||
onClick={() => onUpdate({ color })}
|
||||
/>
|
||||
))}
|
||||
<ColorPicker value={object.color} size="small" onChange={(_, color) => onUpdate({ color })} />
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="颜色">
|
||||
<ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} />
|
||||
</Field>
|
||||
)}
|
||||
{/*
|
||||
骨骼与姿势入口:只在姿态/动画模式出现,且只对演员出现。
|
||||
规格要求「仅在演员选择时展示现有骨骼/姿势入口」——
|
||||
带动画的普通模型不是演员,不应拿到姿势预设与骨骼控制。
|
||||
*/}
|
||||
{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>
|
||||
<section className="director-pose-section">
|
||||
<div className="director-inspector-section-title"><span>姿势预设</span><span>{directorPoseLabel(object.pose || "stand")}</span></div>
|
||||
<div className="director-pose-grid">{poseOptions.map((option) => <button key={option.value} type="button" className={`director-pose-button ${object.pose === option.value && !object.activeMotionClipId ? "is-active" : ""}`} title={option.label} onClick={() => applyPose(option.value)}>{option.label}</button>)}</div>
|
||||
<Button size="small" block onClick={() => applyPose("stand")}>重置姿态</Button>
|
||||
</section>
|
||||
<div className="flex items-center justify-between border-y py-2 text-[var(--fs-label)]"><span>角色绑定</span><span className="opacity-55">{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}</span></div>
|
||||
{mappedBones.length ? <Field label="骨骼控制"><Select className="w-full" allowClear value={selectedBone || undefined} options={mappedBones.map((bone) => ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} /></Field> : null}
|
||||
{selectedBoneId && selectedBoneRotation ? <><BoneRotationFields rotation={selectedBoneRotation} onChange={onBoneRotationStage} onChangeComplete={onBoneRotationCommit} /><Button size="small" block onClick={resetSelectedBone}>重置当前骨骼</Button></> : null}
|
||||
{/* 演员还没加载出模型时给一句解释,避免「动作片段」区域凭空消失。 */}
|
||||
{motionClips.length ? null : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</div>}
|
||||
</> : null}
|
||||
{/* 动作片段是动画内容而非骨骼入口:任何带 Clip 的对象都能调,不限演员。 */}
|
||||
{motionClips.length ? <><Field label="动作片段"><Select className="w-full" value={object.activeMotionClipId || ""} options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} /></Field>{activeMotionClip ? <div className="grid grid-cols-2 gap-2"><Field label="播放速度"><InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} /></Field><Field label="循环"><Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} /></Field></div> : null}</> : null}
|
||||
<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field>
|
||||
<Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field>
|
||||
{/* 记录关键帧属于动画模式;摆场与姿态模式不默认制造关键帧。 */}
|
||||
{capabilities.keyframes ? <>
|
||||
<Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>{selectedBone ? `在 ${playhead.toFixed(1)}s 记录骨骼` : `在 ${playhead.toFixed(1)}s 记录关键帧`}</Button>
|
||||
<div className="text-[var(--fs-tiny)] opacity-50">Transform {object.keyframes.length} 个 · 骨骼 {object.boneTracks?.reduce((sum, track) => sum + track.keyframes.length, 0) || 0} 个</div>
|
||||
</> : null}
|
||||
</Inspector>;
|
||||
{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? (
|
||||
<>
|
||||
<section className="director-pose-section">
|
||||
<div className="director-inspector-section-title">
|
||||
<span>姿势预设</span>
|
||||
<span>{directorPoseLabel(object.pose || "stand")}</span>
|
||||
</div>
|
||||
<div className="director-pose-grid">
|
||||
{poseOptions.map((option) => (
|
||||
<button key={option.value} type="button" className={`director-pose-button ${object.pose === option.value && !object.activeMotionClipId ? "is-active" : ""}`} title={option.label} onClick={() => applyPose(option.value)}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="small" block onClick={() => applyPose("stand")}>
|
||||
重置姿态
|
||||
</Button>
|
||||
</section>
|
||||
<div className="flex items-center justify-between border-y py-2 text-[var(--fs-label)]">
|
||||
<span>角色绑定</span>
|
||||
<span className="opacity-55">{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}</span>
|
||||
</div>
|
||||
{mappedBones.length ? (
|
||||
<Field label="骨骼控制">
|
||||
<Select className="w-full" allowClear value={selectedBone || undefined} options={mappedBones.map((bone) => ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} />
|
||||
</Field>
|
||||
) : null}
|
||||
{selectedBoneId && selectedBoneRotation ? (
|
||||
<>
|
||||
<BoneRotationFields rotation={selectedBoneRotation} onChange={onBoneRotationStage} onChangeComplete={onBoneRotationCommit} />
|
||||
<Button size="small" block onClick={resetSelectedBone}>
|
||||
重置当前骨骼
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{/* 演员还没加载出模型时给一句解释,避免「动作片段」区域凭空消失。 */}
|
||||
{motionClips.length ? null : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</div>}
|
||||
</>
|
||||
) : null}
|
||||
{/* 动作片段是动画内容而非骨骼入口:任何带 Clip 的对象都能调,不限演员。 */}
|
||||
{motionClips.length ? (
|
||||
<>
|
||||
<Field label="动作片段">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={object.activeMotionClipId || ""}
|
||||
options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]}
|
||||
onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
{activeMotionClip ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="播放速度">
|
||||
<InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} />
|
||||
</Field>
|
||||
<Field label="循环">
|
||||
<Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Field label="可见">
|
||||
<Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} />
|
||||
</Field>
|
||||
<Field label="投射阴影">
|
||||
<Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} />
|
||||
</Field>
|
||||
{/* 记录关键帧属于动画模式;摆场与姿态模式不默认制造关键帧。 */}
|
||||
{capabilities.keyframes ? (
|
||||
<>
|
||||
<Button block icon={<Focus className="size-3.5" />} onClick={onAddKeyframe}>
|
||||
{selectedBone ? `在 ${playhead.toFixed(1)}s 记录骨骼` : `在 ${playhead.toFixed(1)}s 记录关键帧`}
|
||||
</Button>
|
||||
<div className="text-[var(--fs-tiny)] opacity-50">
|
||||
Transform {object.keyframes.length} 个 · 骨骼 {object.boneTracks?.reduce((sum, track) => sum + track.keyframes.length, 0) || 0} 个
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Inspector>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightInspector({ light, onUpdate, onDelete }: { light: DirectorLight; onUpdate: (patch: Partial<DirectorLight>) => void; onDelete: () => void }) {
|
||||
return <Inspector title={light.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}><Field label="类型"><Select className="w-full" value={light.type} options={[{ label: "方向光", value: "directional" }, { label: "点光源", value: "point" }, { label: "聚光灯", value: "spot" }, { label: "环境光", value: "ambient" }]} onChange={(type) => onUpdate({ type })} /></Field><Vec3Field label="位置" value={light.transform.position} onChange={(position) => onUpdate({ transform: { ...light.transform, position } })} /><Field label="颜色"><ColorPicker value={light.color} onChange={(_, color) => onUpdate({ color })} /></Field><Field label="强度"><InputNumber className="w-full" min={0} max={20} step={0.1} value={light.intensity} onChange={(value) => onUpdate({ intensity: value || 0 })} /></Field><Field label="投射阴影"><Switch checked={light.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field></Inspector>;
|
||||
return (
|
||||
<Inspector title={light.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
|
||||
<Field label="类型">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={light.type}
|
||||
options={[
|
||||
{ label: "方向光", value: "directional" },
|
||||
{ label: "点光源", value: "point" },
|
||||
{ label: "聚光灯", value: "spot" },
|
||||
{ label: "环境光", value: "ambient" },
|
||||
]}
|
||||
onChange={(type) => onUpdate({ type })}
|
||||
/>
|
||||
</Field>
|
||||
<Vec3Field label="位置" value={light.transform.position} onChange={(position) => onUpdate({ transform: { ...light.transform, position } })} />
|
||||
<Field label="颜色">
|
||||
<ColorPicker value={light.color} onChange={(_, color) => onUpdate({ color })} />
|
||||
</Field>
|
||||
<Field label="强度">
|
||||
<InputNumber className="w-full" min={0} max={20} step={0.1} value={light.intensity} onChange={(value) => onUpdate({ intensity: value || 0 })} />
|
||||
</Field>
|
||||
<Field label="投射阴影">
|
||||
<Switch checked={light.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} />
|
||||
</Field>
|
||||
</Inspector>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShotInspector({ shot, camera, cameras, capabilities, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; capabilities: DirectorModeCapabilities; onUpdateShot: (patch: Partial<DirectorShot>) => void; onUpdateCamera: (patch: Partial<DirectorCamera>) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) {
|
||||
return <Inspector title={shot.name} onTitleChange={(name) => onUpdateShot({ name })}>
|
||||
<Field label="摄影机"><Select className="w-full" value={shot.cameraId} options={cameras.map((item) => ({ label: item.name, value: item.id }))} onChange={(cameraId) => onUpdateShot({ cameraId })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-2"><Field label="景别"><Select className="w-full" value={shot.shotSize} options={shotSizeOptions} onChange={(shotSize: DirectorShotSize) => onUpdateShot({ shotSize })} /></Field><Field label="帧率"><Select className="w-full" value={shot.fps} options={[24, 25, 30].map((fps) => ({ label: `${fps} fps`, value: fps }))} onChange={(fps: 24 | 25 | 30) => onUpdateShot({ fps })} /></Field></div>
|
||||
<Field label="运镜"><Select className="w-full" value={shot.cameraMove} options={cameraMoveOptions} onChange={(cameraMove: DirectorCameraMove) => onUpdateShot({ cameraMove })} /></Field>
|
||||
<Field label="时长"><InputNumber className="w-full" min={0.5} max={60} step={0.5} value={shot.duration} addonAfter="秒" onChange={(value) => onUpdateShot({ duration: value || 5 })} /></Field>
|
||||
<Field label="镜头意图"><Input.TextArea autoSize={{ minRows: 3, maxRows: 7 }} value={shot.prompt} placeholder="人物表演、动作、叙事目标…" onChange={(event) => onUpdateShot({ prompt: event.target.value })} /></Field>
|
||||
{camera ? <><Vec3Field label="摄影机位置" value={camera.transform.position} onChange={(position) => onUpdateCamera({ transform: { ...camera.transform, position } })} /><Vec3Field label="焦点" value={camera.target} onChange={(target) => onUpdateCamera({ target })} /><Field label="焦距"><InputNumber className="w-full" min={12} max={200} value={camera.focalLength} addonAfter="mm" onChange={(focalLength) => onUpdateCamera({ focalLength: focalLength || 35, fov: directorFocalLengthToFov(focalLength || 35) })} /></Field><div className="grid grid-cols-2 gap-2"><Field label="光圈"><InputNumber className="w-full" min={0.7} max={32} step={0.1} value={camera.aperture} addonBefore="f/" onChange={(aperture) => onUpdateCamera({ aperture: aperture || 2.8 })} /></Field><Field label="焦点距离"><InputNumber className="w-full" min={0.1} max={200} step={0.1} value={camera.focusDistance} addonAfter="m" onChange={(focusDistance) => onUpdateCamera({ focusDistance: focusDistance || 5 })} /></Field></div><Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>摄影机对齐当前视图</Button><Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>按运镜生成轨迹</Button>{capabilities.keyframes ? <Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>记录摄影机关键帧</Button> : null}<Button block type="primary" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={onExportClay}>导出白膜视频</Button></> : null}
|
||||
</Inspector>;
|
||||
export function ShotInspector({
|
||||
shot,
|
||||
camera,
|
||||
cameras,
|
||||
capabilities,
|
||||
onUpdateShot,
|
||||
onUpdateCamera,
|
||||
onAddCameraKeyframe,
|
||||
onApplyCameraMove,
|
||||
onAlignCameraToView,
|
||||
onExportClay,
|
||||
recording,
|
||||
}: {
|
||||
shot: DirectorShot;
|
||||
camera: DirectorCamera | null;
|
||||
cameras: DirectorScene["cameras"];
|
||||
capabilities: DirectorModeCapabilities;
|
||||
onUpdateShot: (patch: Partial<DirectorShot>) => void;
|
||||
onUpdateCamera: (patch: Partial<DirectorCamera>) => void;
|
||||
onAddCameraKeyframe: () => void;
|
||||
onApplyCameraMove: () => void;
|
||||
onAlignCameraToView: () => void;
|
||||
onExportClay: () => void;
|
||||
recording: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Inspector title={shot.name} onTitleChange={(name) => onUpdateShot({ name })}>
|
||||
<Field label="摄影机">
|
||||
<Select className="w-full" value={shot.cameraId} options={cameras.map((item) => ({ label: item.name, value: item.id }))} onChange={(cameraId) => onUpdateShot({ cameraId })} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="景别">
|
||||
<Select className="w-full" value={shot.shotSize} options={shotSizeOptions} onChange={(shotSize: DirectorShotSize) => onUpdateShot({ shotSize })} />
|
||||
</Field>
|
||||
<Field label="帧率">
|
||||
<Select className="w-full" value={shot.fps} options={[24, 25, 30].map((fps) => ({ label: `${fps} fps`, value: fps }))} onChange={(fps: 24 | 25 | 30) => onUpdateShot({ fps })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="运镜">
|
||||
<Select className="w-full" value={shot.cameraMove} options={cameraMoveOptions} onChange={(cameraMove: DirectorCameraMove) => onUpdateShot({ cameraMove })} />
|
||||
</Field>
|
||||
<Field label="时长">
|
||||
<InputNumber className="w-full" min={0.5} max={60} step={0.5} value={shot.duration} addonAfter="秒" onChange={(value) => onUpdateShot({ duration: value || 5 })} />
|
||||
</Field>
|
||||
<Field label="镜头意图">
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 7 }} value={shot.prompt} placeholder="人物表演、动作、叙事目标…" onChange={(event) => onUpdateShot({ prompt: event.target.value })} />
|
||||
</Field>
|
||||
{camera ? (
|
||||
<>
|
||||
<Vec3Field label="摄影机位置" value={camera.transform.position} onChange={(position) => onUpdateCamera({ transform: { ...camera.transform, position } })} />
|
||||
<Vec3Field label="焦点" value={camera.target} onChange={(target) => onUpdateCamera({ target })} />
|
||||
<Field label="焦距">
|
||||
<InputNumber className="w-full" min={12} max={200} value={camera.focalLength} addonAfter="mm" onChange={(focalLength) => onUpdateCamera({ focalLength: focalLength || 35, fov: directorFocalLengthToFov(focalLength || 35) })} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="光圈">
|
||||
<InputNumber className="w-full" min={0.7} max={32} step={0.1} value={camera.aperture} addonBefore="f/" onChange={(aperture) => onUpdateCamera({ aperture: aperture || 2.8 })} />
|
||||
</Field>
|
||||
<Field label="焦点距离">
|
||||
<InputNumber className="w-full" min={0.1} max={200} step={0.1} value={camera.focusDistance} addonAfter="m" onChange={(focusDistance) => onUpdateCamera({ focusDistance: focusDistance || 5 })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Button block icon={<Camera className="size-3.5" />} onClick={onAlignCameraToView}>
|
||||
摄影机对齐当前视图
|
||||
</Button>
|
||||
<Button block icon={<Video className="size-3.5" />} onClick={onApplyCameraMove}>
|
||||
按运镜生成轨迹
|
||||
</Button>
|
||||
{capabilities.keyframes ? (
|
||||
<Button block icon={<Focus className="size-3.5" />} onClick={onAddCameraKeyframe}>
|
||||
记录摄影机关键帧
|
||||
</Button>
|
||||
) : null}
|
||||
<Button block type="primary" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={onExportClay}>
|
||||
导出白膜视频
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Inspector>
|
||||
);
|
||||
}
|
||||
|
||||
export function Inspector({ title, children, onTitleChange, onDelete }: { title: string; children: ReactNode; onTitleChange: (value: string) => void; onDelete?: () => void }) {
|
||||
return <div className="space-y-3 p-3"><div className="flex items-center gap-2"><Input variant="borderless" value={title} className="min-w-0 flex-1 px-0 font-medium" onChange={(event) => onTitleChange(event.target.value)} />{onDelete ? <IconButton label="删除" onClick={onDelete}><Trash2 className="size-4" /></IconButton> : null}</div>{children}</div>;
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input variant="borderless" value={title} className="min-w-0 flex-1 px-0 font-medium" onChange={(event) => onTitleChange(event.target.value)} />
|
||||
{onDelete ? (
|
||||
<IconButton label="删除" onClick={onDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransformFields({ transform, onChange }: { transform: DirectorTransform; onChange: (transform: DirectorTransform) => void }) {
|
||||
return <><Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} /><Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} /><Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} /></>;
|
||||
return (
|
||||
<>
|
||||
<Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} />
|
||||
<Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} />
|
||||
<Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoneRotationFields({ rotation, onChange, onChangeComplete }: { rotation: DirectorQuat; onChange: (rotation: DirectorQuat) => void; onChangeComplete: () => void }) {
|
||||
@@ -123,20 +374,26 @@ export function BoneRotationFields({ rotation, onChange, onChangeComplete }: { r
|
||||
setDegrees(initialDegrees);
|
||||
}, [initialDegrees, rotation]);
|
||||
const updateAxis = (index: number, value: number) => {
|
||||
const next = degrees.map((entry, entryIndex) => entryIndex === index ? value : entry) as DirectorVec3;
|
||||
const next = degrees.map((entry, entryIndex) => (entryIndex === index ? value : entry)) as DirectorVec3;
|
||||
const radians = next.map((entry) => (entry * Math.PI) / 180) as DirectorVec3;
|
||||
const nextRotation = new Quaternion().setFromEuler(new Euler(radians[0], radians[1], radians[2], "XYZ")).toArray() as DirectorQuat;
|
||||
setDegrees(next);
|
||||
lastEmittedRotation.current = nextRotation;
|
||||
onChange(nextRotation);
|
||||
};
|
||||
return <Field label="骨骼旋转(局部角度 °)"><div className="space-y-1.5">
|
||||
{degrees.map((value, index) => <div key={index} className="grid grid-cols-[18px_minmax(0,1fr)_48px] items-center gap-2">
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-65">{["X", "Y", "Z"][index]}</span>
|
||||
<Slider className="m-0" min={-180} max={180} step={1} value={value} onChange={(next) => updateAxis(index, Array.isArray(next) ? next[0] ?? 0 : next)} onChangeComplete={onChangeComplete} />
|
||||
<span className="text-right text-[var(--fs-tiny)] tabular-nums opacity-65">{value.toFixed(1)}°</span>
|
||||
</div>)}
|
||||
</div></Field>;
|
||||
return (
|
||||
<Field label="骨骼旋转(局部角度 °)">
|
||||
<div className="space-y-1.5">
|
||||
{degrees.map((value, index) => (
|
||||
<div key={index} className="grid grid-cols-[18px_minmax(0,1fr)_48px] items-center gap-2">
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-65">{["X", "Y", "Z"][index]}</span>
|
||||
<Slider className="m-0" min={-180} max={180} step={1} value={value} onChange={(next) => updateAxis(index, Array.isArray(next) ? (next[0] ?? 0) : next)} onChangeComplete={onChangeComplete} />
|
||||
<span className="text-right text-[var(--fs-tiny)] tabular-nums opacity-65">{value.toFixed(1)}°</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function sameDirectorQuaternion(left: DirectorQuat, right: DirectorQuat) {
|
||||
@@ -146,12 +403,34 @@ export function sameDirectorQuaternion(left: DirectorQuat, right: DirectorQuat)
|
||||
}
|
||||
|
||||
export function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; value: DirectorVec3; step?: number; onChange: (value: DirectorVec3) => void }) {
|
||||
return <Field label={label}><div className="grid grid-cols-3 gap-1">{value.map((item, index) => <InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => itemIndex === index ? next || 0 : entry) as DirectorVec3)} />)}</div></Field>;
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{value.map((item, index) => (
|
||||
<InputNumber key={index} className="w-full" size="small" step={step} value={Number(item.toFixed(2))} onChange={(next) => onChange(value.map((entry, itemIndex) => (itemIndex === index ? next || 0 : entry)) as DirectorVec3)} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="block"><span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>{children}</label>; }
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[var(--fs-label)] opacity-55">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return <div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55"><span className="flex-1">{title}</span>{action}</div>; }
|
||||
export function PanelTitle({ title, action }: { title: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-9 items-center px-3 text-[var(--fs-tiny)] font-semibold uppercase opacity-55">
|
||||
<span className="flex-1">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景列表行。选择按钮点完必须释放焦点:
|
||||
@@ -159,19 +438,78 @@ export function PanelTitle({ title, action }: { title: string; action?: ReactNod
|
||||
* 焦点留在按钮上会让守卫把 Delete 吃掉。
|
||||
*/
|
||||
export function SceneRow({ active, icon, label, onClick, onDelete }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void; onDelete?: () => void }) {
|
||||
return <div className={`flex h-8 w-full items-center gap-1 px-1 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`}>
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left" onClick={(event) => { onClick(); releaseDirectorFocusAfterPointer(event); }}>
|
||||
<span className="[&>svg]:size-3.5">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{onDelete ? <button type="button" aria-label={`删除${label}`} title={`删除${label}`} className="grid size-6 shrink-0 place-items-center rounded opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10" onClick={(event) => { event.stopPropagation(); onDelete(); releaseDirectorFocusAfterPointer(event); }}><Trash2 className="size-3.5" /></button> : null}
|
||||
</div>;
|
||||
return (
|
||||
<div className={`flex h-8 w-full items-center gap-1 px-1 text-left text-xs transition ${active ? "bg-black/10 dark:bg-white/10" : "hover:bg-black/5 dark:hover:bg-white/5"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
|
||||
onClick={(event) => {
|
||||
onClick();
|
||||
releaseDirectorFocusAfterPointer(event);
|
||||
}}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除${label}`}
|
||||
title={`删除${label}`}
|
||||
className="grid size-6 shrink-0 place-items-center rounded opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
releaseDirectorFocusAfterPointer(event);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddMenuButton({ label, items }: { label: string; items: MenuProps["items"] }) {
|
||||
return <Dropdown trigger={["click"]} placement="bottomRight" menu={{ items }}><button type="button" aria-label={label} title={label} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 dark:hover:bg-white/10"><Plus className="size-3.5" /></button></Dropdown>;
|
||||
return (
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" menu={{ items }}>
|
||||
<button type="button" aria-label={label} title={label} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 dark:hover:bg-white/10">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return <button type="button" className="flex h-8 items-center gap-1.5 border px-2 text-[var(--fs-tiny)] transition hover:bg-black/5 dark:hover:bg-white/5" onClick={(event) => { onClick(); releaseDirectorFocusAfterPointer(event); }}><span className="[&>svg]:size-3.5">{icon}</span><span className="truncate">{label}</span></button>; }
|
||||
export function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1.5 border px-2 text-[var(--fs-tiny)] transition hover:bg-black/5 dark:hover:bg-white/5"
|
||||
onClick={(event) => {
|
||||
onClick();
|
||||
releaseDirectorFocusAfterPointer(event);
|
||||
}}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return <button type="button" aria-label={label} title={label} disabled={disabled} className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10" onClick={(event) => { onClick(); releaseDirectorFocusAfterPointer(event); }}>{children}</button>; }
|
||||
export function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
className="grid size-8 shrink-0 place-items-center rounded-md transition hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
|
||||
onClick={(event) => {
|
||||
onClick();
|
||||
releaseDirectorFocusAfterPointer(event);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,19 @@ export async function recordCanvas(context: CaptureContext | null, duration: num
|
||||
};
|
||||
window.addEventListener("error", onRenderError);
|
||||
const result = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onerror = () => reject(new Error("白膜视频录制失败"));
|
||||
recorder.onstop = () => resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
|
||||
});
|
||||
recorder.start(250);
|
||||
const stopTimer = window.setTimeout(() => { if (recorder.state !== "inactive") recorder.stop(); }, Math.max(250, duration * 1000 + 120));
|
||||
const stopTimer = window.setTimeout(
|
||||
() => {
|
||||
if (recorder.state !== "inactive") recorder.stop();
|
||||
},
|
||||
Math.max(250, duration * 1000 + 120),
|
||||
);
|
||||
try {
|
||||
const blob = await result;
|
||||
if (renderError) throw renderError;
|
||||
@@ -84,7 +91,10 @@ export async function probeRecordedDuration(blob: Blob) {
|
||||
});
|
||||
if (video.duration !== Infinity) return video.duration;
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = () => { video.removeEventListener("seeked", finish); resolve(); };
|
||||
const finish = () => {
|
||||
video.removeEventListener("seeked", finish);
|
||||
resolve();
|
||||
};
|
||||
video.addEventListener("seeked", finish);
|
||||
video.currentTime = 1e6;
|
||||
window.setTimeout(finish, 1000);
|
||||
|
||||
@@ -64,15 +64,19 @@ export function updateActorReferenceColor(root: Object3D, color: string) {
|
||||
}
|
||||
|
||||
export function readRigRestRotations(root: Object3D, rig: DirectorRig) {
|
||||
return Object.fromEntries(Object.entries(rig.boneMap).flatMap(([bone, name]) => {
|
||||
const target = name ? root.getObjectByName(name) : null;
|
||||
return target ? [[bone, target.quaternion.toArray() as DirectorQuat]] : [];
|
||||
})) as Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
|
||||
return Object.fromEntries(
|
||||
Object.entries(rig.boneMap).flatMap(([bone, name]) => {
|
||||
const target = name ? root.getObjectByName(name) : null;
|
||||
return target ? [[bone, target.quaternion.toArray() as DirectorQuat]] : [];
|
||||
}),
|
||||
) as Partial<Record<DirectorHumanoidBone, DirectorQuat>>;
|
||||
}
|
||||
|
||||
export function inferDirectorRig(root: Object3D, animationNames: string[]): DirectorRig {
|
||||
const names = new Map<string, string>();
|
||||
root.traverse((child) => { if (child instanceof Bone) names.set(normalizeBoneName(child.name), child.name); });
|
||||
root.traverse((child) => {
|
||||
if (child instanceof Bone) names.set(normalizeBoneName(child.name), child.name);
|
||||
});
|
||||
const fingerPatterns = (side: "left" | "right", finger: "thumb" | "index" | "middle" | "ring" | "pinky", segment: 1 | 2 | 3) => [
|
||||
new RegExp(`^mixamorig${side}hand${finger}${segment}$`),
|
||||
new RegExp(`^${side}hand${finger}${segment}$`),
|
||||
@@ -80,22 +84,62 @@ export function inferDirectorRig(root: Object3D, animationNames: string[]): Dire
|
||||
new RegExp(`^${finger}0?${segment}${side === "left" ? "l" : "r"}$`),
|
||||
];
|
||||
const patterns: Record<DirectorHumanoidBone, RegExp[]> = {
|
||||
root: [/^root$/, /armature/], hips: [/hips|pelvis/, /mixamorig.*hip/], spine: [/spine1?$|lowerback/], chest: [/spine2|chest|upperback/], neck: [/neck/], head: [/head/],
|
||||
leftShoulder: [/leftshoulder|shoulder_l|mixamorigleftshoulder/], leftUpperArm: [/leftupperarm|leftarm|upperarm_l|mixamorigleftarm/], leftLowerArm: [/leftforearm|leftlowerarm|forearm_l|mixamorigleftforearm/], leftHand: [/^lefthand$/, /^handl$/, /^mixamoriglefthand$/],
|
||||
leftThumb1: fingerPatterns("left", "thumb", 1), leftThumb2: fingerPatterns("left", "thumb", 2), leftThumb3: fingerPatterns("left", "thumb", 3),
|
||||
leftIndex1: fingerPatterns("left", "index", 1), leftIndex2: fingerPatterns("left", "index", 2), leftIndex3: fingerPatterns("left", "index", 3),
|
||||
leftMiddle1: fingerPatterns("left", "middle", 1), leftMiddle2: fingerPatterns("left", "middle", 2), leftMiddle3: fingerPatterns("left", "middle", 3),
|
||||
leftRing1: fingerPatterns("left", "ring", 1), leftRing2: fingerPatterns("left", "ring", 2), leftRing3: fingerPatterns("left", "ring", 3),
|
||||
leftPinky1: fingerPatterns("left", "pinky", 1), leftPinky2: fingerPatterns("left", "pinky", 2), leftPinky3: fingerPatterns("left", "pinky", 3),
|
||||
rightShoulder: [/rightshoulder|shoulder_r|mixamorigrightshoulder/], rightUpperArm: [/rightupperarm|rightarm|upperarm_r|mixamorigrightarm/], rightLowerArm: [/rightforearm|rightlowerarm|forearm_r|mixamorigrightforearm/], rightHand: [/^righthand$/, /^handr$/, /^mixamorigrighthand$/],
|
||||
rightThumb1: fingerPatterns("right", "thumb", 1), rightThumb2: fingerPatterns("right", "thumb", 2), rightThumb3: fingerPatterns("right", "thumb", 3),
|
||||
rightIndex1: fingerPatterns("right", "index", 1), rightIndex2: fingerPatterns("right", "index", 2), rightIndex3: fingerPatterns("right", "index", 3),
|
||||
rightMiddle1: fingerPatterns("right", "middle", 1), rightMiddle2: fingerPatterns("right", "middle", 2), rightMiddle3: fingerPatterns("right", "middle", 3),
|
||||
rightRing1: fingerPatterns("right", "ring", 1), rightRing2: fingerPatterns("right", "ring", 2), rightRing3: fingerPatterns("right", "ring", 3),
|
||||
rightPinky1: fingerPatterns("right", "pinky", 1), rightPinky2: fingerPatterns("right", "pinky", 2), rightPinky3: fingerPatterns("right", "pinky", 3),
|
||||
leftUpperLeg: [/leftupleg|leftthigh|thigh_l|mixamorigleftupleg/], leftLowerLeg: [/leftleg|leftcalf|calf_l|mixamorigleftleg/], leftFoot: [/leftfoot|foot_l|mixamorigleftfoot/], rightUpperLeg: [/rightupleg|rightthigh|thigh_r|mixamorigrightupleg/], rightLowerLeg: [/rightleg|rightcalf|calf_r|mixamorigrightleg/], rightFoot: [/rightfoot|foot_r|mixamorigrightfoot/],
|
||||
root: [/^root$/, /armature/],
|
||||
hips: [/hips|pelvis/, /mixamorig.*hip/],
|
||||
spine: [/spine1?$|lowerback/],
|
||||
chest: [/spine2|chest|upperback/],
|
||||
neck: [/neck/],
|
||||
head: [/head/],
|
||||
leftShoulder: [/leftshoulder|shoulder_l|mixamorigleftshoulder/],
|
||||
leftUpperArm: [/leftupperarm|leftarm|upperarm_l|mixamorigleftarm/],
|
||||
leftLowerArm: [/leftforearm|leftlowerarm|forearm_l|mixamorigleftforearm/],
|
||||
leftHand: [/^lefthand$/, /^handl$/, /^mixamoriglefthand$/],
|
||||
leftThumb1: fingerPatterns("left", "thumb", 1),
|
||||
leftThumb2: fingerPatterns("left", "thumb", 2),
|
||||
leftThumb3: fingerPatterns("left", "thumb", 3),
|
||||
leftIndex1: fingerPatterns("left", "index", 1),
|
||||
leftIndex2: fingerPatterns("left", "index", 2),
|
||||
leftIndex3: fingerPatterns("left", "index", 3),
|
||||
leftMiddle1: fingerPatterns("left", "middle", 1),
|
||||
leftMiddle2: fingerPatterns("left", "middle", 2),
|
||||
leftMiddle3: fingerPatterns("left", "middle", 3),
|
||||
leftRing1: fingerPatterns("left", "ring", 1),
|
||||
leftRing2: fingerPatterns("left", "ring", 2),
|
||||
leftRing3: fingerPatterns("left", "ring", 3),
|
||||
leftPinky1: fingerPatterns("left", "pinky", 1),
|
||||
leftPinky2: fingerPatterns("left", "pinky", 2),
|
||||
leftPinky3: fingerPatterns("left", "pinky", 3),
|
||||
rightShoulder: [/rightshoulder|shoulder_r|mixamorigrightshoulder/],
|
||||
rightUpperArm: [/rightupperarm|rightarm|upperarm_r|mixamorigrightarm/],
|
||||
rightLowerArm: [/rightforearm|rightlowerarm|forearm_r|mixamorigrightforearm/],
|
||||
rightHand: [/^righthand$/, /^handr$/, /^mixamorigrighthand$/],
|
||||
rightThumb1: fingerPatterns("right", "thumb", 1),
|
||||
rightThumb2: fingerPatterns("right", "thumb", 2),
|
||||
rightThumb3: fingerPatterns("right", "thumb", 3),
|
||||
rightIndex1: fingerPatterns("right", "index", 1),
|
||||
rightIndex2: fingerPatterns("right", "index", 2),
|
||||
rightIndex3: fingerPatterns("right", "index", 3),
|
||||
rightMiddle1: fingerPatterns("right", "middle", 1),
|
||||
rightMiddle2: fingerPatterns("right", "middle", 2),
|
||||
rightMiddle3: fingerPatterns("right", "middle", 3),
|
||||
rightRing1: fingerPatterns("right", "ring", 1),
|
||||
rightRing2: fingerPatterns("right", "ring", 2),
|
||||
rightRing3: fingerPatterns("right", "ring", 3),
|
||||
rightPinky1: fingerPatterns("right", "pinky", 1),
|
||||
rightPinky2: fingerPatterns("right", "pinky", 2),
|
||||
rightPinky3: fingerPatterns("right", "pinky", 3),
|
||||
leftUpperLeg: [/leftupleg|leftthigh|thigh_l|mixamorigleftupleg/],
|
||||
leftLowerLeg: [/leftleg|leftcalf|calf_l|mixamorigleftleg/],
|
||||
leftFoot: [/leftfoot|foot_l|mixamorigleftfoot/],
|
||||
rightUpperLeg: [/rightupleg|rightthigh|thigh_r|mixamorigrightupleg/],
|
||||
rightLowerLeg: [/rightleg|rightcalf|calf_r|mixamorigrightleg/],
|
||||
rightFoot: [/rightfoot|foot_r|mixamorigrightfoot/],
|
||||
};
|
||||
const boneMap = Object.fromEntries(Object.entries(patterns).map(([bone, candidates]) => [bone, candidates.map((pattern) => [...names.entries()].find(([normalized]) => pattern.test(normalized))?.[1]).find(Boolean)]).filter(([, name]) => Boolean(name))) as DirectorRig["boneMap"];
|
||||
const boneMap = Object.fromEntries(
|
||||
Object.entries(patterns)
|
||||
.map(([bone, candidates]) => [bone, candidates.map((pattern) => [...names.entries()].find(([normalized]) => pattern.test(normalized))?.[1]).find(Boolean)])
|
||||
.filter(([, name]) => Boolean(name)),
|
||||
) as DirectorRig["boneMap"];
|
||||
return { status: Object.keys(boneMap).length >= 8 ? "ready" : "unmapped", boneMap, animationNames };
|
||||
}
|
||||
|
||||
@@ -112,7 +156,7 @@ export function applyDirectorBoneTracks(model: Object3D, object: DirectorObject,
|
||||
const humanoidBone = bone as DirectorHumanoidBone;
|
||||
// hasActiveMotion 时 mixer 已写入 target.quaternion,直接作为动作层输入。
|
||||
const rotation = resolveDirectorBoneRotation({
|
||||
motion: hasActiveMotion ? target.quaternion.toArray() as DirectorQuat : null,
|
||||
motion: hasActiveMotion ? (target.quaternion.toArray() as DirectorQuat) : null,
|
||||
rest: hasActiveMotion ? null : restRotations[humanoidBone] || null,
|
||||
poseDelta: hasActiveMotion ? null : poseDeltas[humanoidBone] || null,
|
||||
override: object.boneOverrides?.[humanoidBone] || null,
|
||||
|
||||
@@ -47,7 +47,6 @@ export function formatDateTime(value?: string | null) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(date).replaceAll("/", "-");
|
||||
}
|
||||
|
||||
|
||||
export function isMutationResultUncertain(error: unknown) {
|
||||
if (!(error instanceof ApiError)) return true;
|
||||
return error.status === undefined || error.retryable || error.status >= 500;
|
||||
|
||||
@@ -19,7 +19,6 @@ export const FILE_RULES: Record<AppearanceAssetSlot, { accept: string; maxBytes:
|
||||
video: { accept: "video/mp4,video/webm", maxBytes: 256 << 20, label: "品牌视频" },
|
||||
};
|
||||
|
||||
|
||||
export function AssetPicker({
|
||||
slot,
|
||||
title,
|
||||
|
||||
@@ -48,7 +48,6 @@ export type StoragePayload = Pick<
|
||||
| "allowUserS3"
|
||||
>;
|
||||
|
||||
|
||||
export function formValues(setting: AdminOSSSetting): OSSFormValues {
|
||||
return {
|
||||
mode: setting.enabled ? setting.provider : "local",
|
||||
|
||||
@@ -19,7 +19,6 @@ export const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
|
||||
model: Box,
|
||||
};
|
||||
|
||||
|
||||
export function assetSummary(asset: LibraryAsset) {
|
||||
if (asset.kind === "text") return asset.data.content;
|
||||
if (asset.kind === "audio") return `${formatAssetDuration(asset.data.durationMs)} · ${formatBytes(asset.data.bytes)} · ${asset.data.mimeType}`;
|
||||
|
||||
@@ -18,13 +18,19 @@ export const creationEmptyBannerFrames = [
|
||||
|
||||
export function CreationEmptyBanner() {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className="creation-empty-art" aria-hidden="true">
|
||||
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||||
<img src={frame.src} alt="" />
|
||||
<span>{frame.caption}</span>
|
||||
</figure>)}
|
||||
<span className="creation-empty-art-caption"><span>{brandName}</span>把每一帧,交给镜头导演</span>
|
||||
</div>;
|
||||
return (
|
||||
<div className="creation-empty-art" aria-hidden="true">
|
||||
{creationEmptyBannerFrames.map((frame, index) => (
|
||||
<figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||||
<img src={frame.src} alt="" />
|
||||
<span>{frame.caption}</span>
|
||||
</figure>
|
||||
))}
|
||||
<span className="creation-empty-art-caption">
|
||||
<span>{brandName}</span>把每一帧,交给镜头导演
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
|
||||
@@ -37,61 +43,108 @@ export const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof
|
||||
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
return <LayoutGroup id="creation-empty-suggest">
|
||||
<div className="creation-empty-suggest" aria-label="快捷创作入口">
|
||||
{creationEmptySuggestions.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const start = () => {
|
||||
if (item.openLibrary) onOpenLibrary();
|
||||
else onStartPrompt(item.mode, item.prompt);
|
||||
};
|
||||
return <motion.button
|
||||
key={item.title}
|
||||
type="button"
|
||||
className="suggest-card"
|
||||
onClick={start}
|
||||
onHoverStart={() => setHovered(item.title)}
|
||||
onHoverEnd={() => setHovered(null)}
|
||||
whileHover={reducedMotion ? undefined : { y: -2 }}
|
||||
transition={aceternityMotion.spring.surface}
|
||||
>
|
||||
{hovered === item.title ? (
|
||||
<motion.span
|
||||
layoutId="creation-suggest-hover"
|
||||
className="suggest-card-hover"
|
||||
aria-hidden
|
||||
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface}
|
||||
/>
|
||||
) : null}
|
||||
<span className="library-icon-tile suggest-icon"><Icon size={18} strokeWidth={2} /></span>
|
||||
<span className="suggest-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.hint}</span>
|
||||
</span>
|
||||
</motion.button>;
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>;
|
||||
return (
|
||||
<LayoutGroup id="creation-empty-suggest">
|
||||
<div className="creation-empty-suggest" aria-label="快捷创作入口">
|
||||
{creationEmptySuggestions.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const start = () => {
|
||||
if (item.openLibrary) onOpenLibrary();
|
||||
else onStartPrompt(item.mode, item.prompt);
|
||||
};
|
||||
return (
|
||||
<motion.button
|
||||
key={item.title}
|
||||
type="button"
|
||||
className="suggest-card"
|
||||
onClick={start}
|
||||
onHoverStart={() => setHovered(item.title)}
|
||||
onHoverEnd={() => setHovered(null)}
|
||||
whileHover={reducedMotion ? undefined : { y: -2 }}
|
||||
transition={aceternityMotion.spring.surface}
|
||||
>
|
||||
{hovered === item.title ? <motion.span layoutId="creation-suggest-hover" className="suggest-card-hover" aria-hidden transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface} /> : null}
|
||||
<span className="library-icon-tile suggest-icon">
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="suggest-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.hint}</span>
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
|
||||
const [filter, setFilter] = useState<"all" | CreationMode>("all");
|
||||
const [limit, setLimit] = useState(12);
|
||||
const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter);
|
||||
return <section className="creation-featured-works" aria-labelledby="creation-featured-title">
|
||||
<div className="creation-featured-heading">
|
||||
<div><h2 id="creation-featured-title">精选灵感</h2></div>
|
||||
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
|
||||
</div>
|
||||
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
|
||||
{(["all", "video", "image", "text"] as const).map((value) => <button key={value} type="button" aria-pressed={filter === value} onClick={() => { setFilter(value); setLimit(12); }}>{value === "all" ? "全部灵感" : modeLabels[value]}<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span></button>)}
|
||||
</div>
|
||||
<div className="creation-featured-layout">
|
||||
{filtered.slice(0, limit).map((item, index) => <button key={item.title} type="button" className={`product-collection-card creation-featured-card ${index === 0 ? "is-featured-hero" : ""}`} onClick={() => onStartPrompt(item.mode, item.prompt)}>
|
||||
<span className="creation-featured-media"><img src={item.image} alt="" loading="lazy" /><span className="creation-inspiration-overlay"><ArrowUp />使用这个创意</span></span>
|
||||
<span className="creation-featured-copy"><strong>{item.title}</strong><span>{item.description}</span><em><Sparkles />{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}</em></span>
|
||||
</button>)}
|
||||
</div>
|
||||
<footer className="creation-inspiration-footer">{limit < filtered.length ? <Button onClick={() => setLimit((count) => count + 12)}>展开更多灵感<ChevronDown /></Button> : <span>已展示全部 {filtered.length} 个创意</span>}<details><summary>模板与封面来源</summary><p>{inspirationSource.notice}</p><a href={inspirationSource.repository} target="_blank" rel="noreferrer">awesome-chatgpt-prompts · CC0</a></details></footer>
|
||||
</section>;
|
||||
return (
|
||||
<section className="creation-featured-works" aria-labelledby="creation-featured-title">
|
||||
<div className="creation-featured-heading">
|
||||
<div>
|
||||
<h2 id="creation-featured-title">精选灵感</h2>
|
||||
</div>
|
||||
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
|
||||
</div>
|
||||
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
|
||||
{(["all", "video", "image", "text"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={filter === value}
|
||||
onClick={() => {
|
||||
setFilter(value);
|
||||
setLimit(12);
|
||||
}}
|
||||
>
|
||||
{value === "all" ? "全部灵感" : modeLabels[value]}
|
||||
<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="creation-featured-layout">
|
||||
{filtered.slice(0, limit).map((item, index) => (
|
||||
<button key={item.title} type="button" className={`product-collection-card creation-featured-card ${index === 0 ? "is-featured-hero" : ""}`} onClick={() => onStartPrompt(item.mode, item.prompt)}>
|
||||
<span className="creation-featured-media">
|
||||
<img src={item.image} alt="" loading="lazy" />
|
||||
<span className="creation-inspiration-overlay">
|
||||
<ArrowUp />
|
||||
使用这个创意
|
||||
</span>
|
||||
</span>
|
||||
<span className="creation-featured-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.description}</span>
|
||||
<em>
|
||||
<Sparkles />
|
||||
{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}
|
||||
</em>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer className="creation-inspiration-footer">
|
||||
{limit < filtered.length ? (
|
||||
<Button onClick={() => setLimit((count) => count + 12)}>
|
||||
展开更多灵感
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
) : (
|
||||
<span>已展示全部 {filtered.length} 个创意</span>
|
||||
)}
|
||||
<details>
|
||||
<summary>模板与封面来源</summary>
|
||||
<p>{inspirationSource.notice}</p>
|
||||
<a href={inspirationSource.repository} target="_blank" rel="noreferrer">
|
||||
awesome-chatgpt-prompts · CC0
|
||||
</a>
|
||||
</details>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,25 @@ export function creationConversationBucket(updatedAt: string): "today" | "yester
|
||||
|
||||
export const creationBucketLabels: Record<"today" | "yesterday" | "week" | "earlier", string> = { today: "今天", yesterday: "昨天", week: "近 7 天", earlier: "更早" };
|
||||
|
||||
export function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, onSelect, onDelete, onRename }: { open: boolean; conversations: CreationConversation[]; activeId: string; onNew: () => void; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void; onRename: (conversation: CreationConversation, title: string) => void }) {
|
||||
export function CreationHistoryDrawer({
|
||||
open,
|
||||
conversations,
|
||||
activeId,
|
||||
onNew,
|
||||
onClose,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
open: boolean;
|
||||
conversations: CreationConversation[];
|
||||
activeId: string;
|
||||
onNew: () => void;
|
||||
onClose: () => void;
|
||||
onSelect: (conversation: CreationConversation) => void;
|
||||
onDelete: (conversation: CreationConversation) => void;
|
||||
onRename: (conversation: CreationConversation, title: string) => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
@@ -78,54 +96,160 @@ export function CreationHistoryDrawer({ open, conversations, activeId, onNew, on
|
||||
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
|
||||
latest?.mode ? modeLabels[latest.mode] : "创作",
|
||||
formatConversationTime(conversation.updatedAt),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(query);
|
||||
});
|
||||
}, [conversations, keyword]);
|
||||
|
||||
return (
|
||||
<AppDrawer
|
||||
flush
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
size="min(440px, 100vw)"
|
||||
closeIcon={<X className="size-4" />}
|
||||
className="creation-history-drawer"
|
||||
rootClassName="creation-history-drawer-root"
|
||||
title={
|
||||
<div className="creation-history-title">
|
||||
<span>历史对话</span>
|
||||
<small>{conversations.length} 个对话</small>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="creation-history-content">
|
||||
<label className="creation-history-search">
|
||||
<Search aria-hidden="true" />
|
||||
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
|
||||
</label>
|
||||
|
||||
return <AppDrawer flush open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
<div className="creation-history-content">
|
||||
<label className="creation-history-search">
|
||||
<Search aria-hidden="true" />
|
||||
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
|
||||
</label>
|
||||
|
||||
<button type="button" className="creation-history-new" onClick={onNew}><span className="creation-history-new-icon"><Plus /></span><span className="creation-history-new-copy"><strong>新建创作</strong><small>开启一个新的创作对话</small></span></button>
|
||||
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
|
||||
{visibleConversations.flatMap((conversation, index) => {
|
||||
const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt));
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
const active = conversation.id === activeId;
|
||||
const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles;
|
||||
return [
|
||||
showGroupHead ? <li key={`${conversation.id}-group`} className="creation-history-group-head"><h4>{creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}</h4></li> : null,
|
||||
<li key={conversation.id} className={active ? "is-active" : undefined}>
|
||||
{renamingId === conversation.id ? (
|
||||
<div className="creation-history-rename">
|
||||
<input ref={renameInputRef} className="creation-history-rename-input" defaultValue={conversation.title.trim() || "新创作"} aria-label="重命名对话标题" autoFocus onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { cancelRename(); } }} onBlur={() => commitRename(conversation)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={menuOpenId === conversation.id ? "creation-history-row is-menu-open" : "creation-history-row"}>
|
||||
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => { setMenuOpenId(null); onSelect(conversation); }}>
|
||||
<span className="creation-history-item-icon" aria-hidden="true"><HistoryTypeIcon /></span>
|
||||
<span className="creation-history-item-text">
|
||||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||||
<span className="creation-history-snippet">{latest ? <><em>{latest.mode ? modeLabels[latest.mode] : "创作"}</em><span>{displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作"}</span></> : <><em>创作</em><span>还没有开始创作</span></>}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className="creation-history-time-slot" aria-hidden={menuOpenId === conversation.id}><time dateTime={conversation.updatedAt}>{formatHistoryRelativeTime(conversation.updatedAt)}</time></span>
|
||||
<Dropdown trigger={["click"]} placement="bottomRight" open={menuOpenId === conversation.id} onOpenChange={(open) => setMenuOpenId(open ? conversation.id : null)} overlayClassName="creation-history-menu-overlay" menu={{ items: [{ key: "rename", label: "重命名", icon: <Pencil /> }, { key: "export", label: "导出对话", icon: <Download /> }, { key: "delete", label: "删除对话", danger: true, icon: <Trash2 /> }], onClick: ({ key }) => { setMenuOpenId(null); if (key === "rename") { beginRename(conversation); } else if (key === "export") { downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你"); drawerToast.success("对话已导出为 Markdown"); } else { onDelete(conversation); } } }}>
|
||||
<button type="button" className={menuOpenId === conversation.id ? "creation-history-more is-open" : "creation-history-more"} aria-label={`更多操作:${conversation.title.trim() || "新创作"}`} onClick={(event) => event.preventDefault()}><MoreHorizontal /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</li>,
|
||||
];
|
||||
})}
|
||||
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
|
||||
</div>
|
||||
</AppDrawer>;
|
||||
<button type="button" className="creation-history-new" onClick={onNew}>
|
||||
<span className="creation-history-new-icon">
|
||||
<Plus />
|
||||
</span>
|
||||
<span className="creation-history-new-copy">
|
||||
<strong>新建创作</strong>
|
||||
<small>开启一个新的创作对话</small>
|
||||
</span>
|
||||
</button>
|
||||
{visibleConversations.length ? (
|
||||
<ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
|
||||
{visibleConversations.flatMap((conversation, index) => {
|
||||
const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt));
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
const active = conversation.id === activeId;
|
||||
const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles;
|
||||
return [
|
||||
showGroupHead ? (
|
||||
<li key={`${conversation.id}-group`} className="creation-history-group-head">
|
||||
<h4>{creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}</h4>
|
||||
</li>
|
||||
) : null,
|
||||
<li key={conversation.id} className={active ? "is-active" : undefined}>
|
||||
{renamingId === conversation.id ? (
|
||||
<div className="creation-history-rename">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="creation-history-rename-input"
|
||||
defaultValue={conversation.title.trim() || "新创作"}
|
||||
aria-label="重命名对话标题"
|
||||
autoFocus
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitRename(conversation)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={menuOpenId === conversation.id ? "creation-history-row is-menu-open" : "creation-history-row"}>
|
||||
<button
|
||||
type="button"
|
||||
className="creation-history-item-main"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => {
|
||||
setMenuOpenId(null);
|
||||
onSelect(conversation);
|
||||
}}
|
||||
>
|
||||
<span className="creation-history-item-icon" aria-hidden="true">
|
||||
<HistoryTypeIcon />
|
||||
</span>
|
||||
<span className="creation-history-item-text">
|
||||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||||
<span className="creation-history-snippet">
|
||||
{latest ? (
|
||||
<>
|
||||
<em>{latest.mode ? modeLabels[latest.mode] : "创作"}</em>
|
||||
<span>{displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<em>创作</em>
|
||||
<span>还没有开始创作</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className="creation-history-time-slot" aria-hidden={menuOpenId === conversation.id}>
|
||||
<time dateTime={conversation.updatedAt}>{formatHistoryRelativeTime(conversation.updatedAt)}</time>
|
||||
</span>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
open={menuOpenId === conversation.id}
|
||||
onOpenChange={(open) => setMenuOpenId(open ? conversation.id : null)}
|
||||
overlayClassName="creation-history-menu-overlay"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "rename", label: "重命名", icon: <Pencil /> },
|
||||
{ key: "export", label: "导出对话", icon: <Download /> },
|
||||
{ key: "delete", label: "删除对话", danger: true, icon: <Trash2 /> },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
setMenuOpenId(null);
|
||||
if (key === "rename") {
|
||||
beginRename(conversation);
|
||||
} else if (key === "export") {
|
||||
downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你");
|
||||
drawerToast.success("对话已导出为 Markdown");
|
||||
} else {
|
||||
onDelete(conversation);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={menuOpenId === conversation.id ? "creation-history-more is-open" : "creation-history-more"}
|
||||
aria-label={`更多操作:${conversation.title.trim() || "新创作"}`}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</li>,
|
||||
];
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>
|
||||
)}
|
||||
</div>
|
||||
</AppDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildConversationExportMarkdown(conversation: CreationConversation, assistantName: string, userName: string) {
|
||||
@@ -133,7 +257,7 @@ export function buildConversationExportMarkdown(conversation: CreationConversati
|
||||
for (const message of conversation.messages) {
|
||||
const stamp = formatMessageTime(message.createdAt);
|
||||
const modeTag = message.mode && message.mode !== "text" ? (message.mode === "image" ? "[图像生成] " : "[视频生成] ") : "";
|
||||
const speaker = message.role === "user" ? (userName || "我") : assistantName;
|
||||
const speaker = message.role === "user" ? userName || "我" : assistantName;
|
||||
if (message.role === "user") {
|
||||
const prompt = displayCreationPrompt(message.content, message.references || []).trim();
|
||||
if (!prompt) continue;
|
||||
|
||||
@@ -24,22 +24,100 @@ import { Button } from "antd";
|
||||
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
|
||||
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage, onContinueCanvas, openingCanvas }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
|
||||
export function CreationMessageView({
|
||||
item,
|
||||
shotNumber,
|
||||
onRetryFailure,
|
||||
onCreateVariant,
|
||||
onEditUserMessage,
|
||||
onContinueCanvas,
|
||||
openingCanvas,
|
||||
}: {
|
||||
item: CreationMessage;
|
||||
shotNumber: number;
|
||||
onRetryFailure: () => void;
|
||||
onCreateVariant: () => void;
|
||||
onEditUserMessage: (text: string) => void;
|
||||
onContinueCanvas: (ids?: string[]) => void;
|
||||
openingCanvas: boolean;
|
||||
}) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
if (item.role === "user") return <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
const heading =
|
||||
mode !== "text" ? (
|
||||
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : "视频生成"}</strong>{item.status === "pending" ? <span className="creation-message-progress-copy">{brandName}正在生成{mode === "video" ? "视频" : "图像"}……</span> : item.status === "done" ? <span className="creation-message-progress-copy">你的{mode === "video" ? "视频" : "图像"}已创建</span> : null}{item.status === "done" ? <button type="button" className="creation-message-variant-action" onClick={onCreateVariant}><RefreshCw />生成同款</button> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
|
||||
<>
|
||||
{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}
|
||||
<span className="creation-message-mark">
|
||||
<Sparkles />
|
||||
</span>
|
||||
<strong>{mode === "image" ? "图像生成" : "视频生成"}</strong>
|
||||
{item.status === "pending" ? (
|
||||
<span className="creation-message-progress-copy">
|
||||
{brandName}正在生成{mode === "video" ? "视频" : "图像"}……
|
||||
</span>
|
||||
) : item.status === "done" ? (
|
||||
<span className="creation-message-progress-copy">你的{mode === "video" ? "视频" : "图像"}已创建</span>
|
||||
) : null}
|
||||
{item.status === "done" ? (
|
||||
<button type="button" className="creation-message-variant-action" onClick={onCreateVariant}>
|
||||
<RefreshCw />
|
||||
生成同款
|
||||
</button>
|
||||
) : null}
|
||||
{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}
|
||||
{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{brandName}</strong>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
|
||||
<>
|
||||
{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}
|
||||
<span className="creation-message-mark">
|
||||
<Sparkles />
|
||||
</span>
|
||||
<strong>{brandName}</strong>
|
||||
{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}
|
||||
{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}
|
||||
</>
|
||||
);
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
return (
|
||||
<article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? (
|
||||
<>
|
||||
<div className="creation-message-heading">{heading}</div>
|
||||
{item.reasoning ? (
|
||||
<div className="creation-message-reasoning-wrap">
|
||||
<MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="creation-message-content">
|
||||
{item.content ? (
|
||||
<AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<WorkingDots dotSize={5} gap={2} />
|
||||
<span>正在生成…</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<GenerationToolCard status={toolStatus} heading={heading}>
|
||||
<MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} />
|
||||
</GenerationToolCard>
|
||||
)}
|
||||
{item.error && mode === "text" ? (
|
||||
<div className="creation-message-error">
|
||||
<span>{generationErrorMessage(item.error)}</span>
|
||||
<button type="button" onClick={onRetryFailure}>
|
||||
<RefreshCw />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onEditUserMessage: (text: string) => void }) {
|
||||
@@ -49,31 +127,93 @@ export function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { i
|
||||
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const userAvatarUrl = user?.avatarUrl?.trim();
|
||||
return <article className="creation-user-message">
|
||||
<div className="creation-user-message-meta">{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<strong>{user?.displayName || "你"}</strong><span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span></div>
|
||||
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
|
||||
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
|
||||
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
||||
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
|
||||
const previewUrl = kind === "image" ? imageUrl : url;
|
||||
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
||||
})}</div> : null}
|
||||
<div className="creation-user-message-actions"><Tooltip title="复制提示词"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip><Tooltip title="编辑并重新发送"><button type="button" className="creation-user-message-edit" aria-label="编辑提示词" onClick={() => onEditUserMessage(visiblePrompt)}><Pencil /></button></Tooltip></div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</article>;
|
||||
return (
|
||||
<article className="creation-user-message">
|
||||
<div className="creation-user-message-meta">
|
||||
{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}
|
||||
{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}
|
||||
<strong>{user?.displayName || "你"}</strong>
|
||||
<span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span>
|
||||
</div>
|
||||
<div className="creation-user-message-copy-wrap">
|
||||
<p>{visiblePrompt}</p>
|
||||
</div>
|
||||
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
|
||||
{item.attachments?.length ? (
|
||||
<div className="creation-user-message-attachments">
|
||||
{item.attachments.map((attachment) => {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
||||
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
|
||||
const previewUrl = kind === "image" ? imageUrl : url;
|
||||
return (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
className={!previewable ? "is-file" : undefined}
|
||||
onClick={() => {
|
||||
if (!previewable) return;
|
||||
setPreviewType(kind === "video" ? "video" : "image");
|
||||
setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl);
|
||||
}}
|
||||
aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"}
|
||||
disabled={previewable && !previewUrl}
|
||||
>
|
||||
{kind === "video" ? (
|
||||
<video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" />
|
||||
) : kind === "image" ? (
|
||||
<CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" />
|
||||
) : kind === "audio" ? (
|
||||
<Music2 />
|
||||
) : (
|
||||
<FileText />
|
||||
)}
|
||||
{previewable ? (
|
||||
<span aria-hidden="true">
|
||||
<Maximize2 />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="creation-user-message-actions">
|
||||
<Tooltip title="复制提示词">
|
||||
<button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}>
|
||||
<Copy />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑并重新发送">
|
||||
<button type="button" className="creation-user-message-edit" aria-label="编辑提示词" onClick={() => onEditUserMessage(visiblePrompt)}>
|
||||
<Pencil />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas, openingCanvas }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
|
||||
export function MediaResult({
|
||||
item,
|
||||
onRetryFailure,
|
||||
onCreateVariant,
|
||||
onContinueCanvas,
|
||||
openingCanvas,
|
||||
}: {
|
||||
item: CreationMessage;
|
||||
onRetryFailure: () => void;
|
||||
onCreateVariant: () => void;
|
||||
onContinueCanvas: (ids?: string[]) => void;
|
||||
openingCanvas: boolean;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const storedResultUrls = item.resultUrls || [];
|
||||
const resultStorageKeys = item.resultStorageKeys?.length
|
||||
? item.resultStorageKeys
|
||||
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
|
||||
const resultStorageKeys = item.resultStorageKeys?.length ? item.resultStorageKeys : creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
|
||||
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,52 +274,192 @@ export function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueC
|
||||
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
|
||||
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount)
|
||||
return (
|
||||
<div className="creation-media-error">
|
||||
<span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span>
|
||||
<button type="button" onClick={onRetryFailure}>
|
||||
<RefreshCw />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!expectedResultCount || !displayResultUrls.length)
|
||||
return (
|
||||
<div className="creation-media-empty">
|
||||
没有返回可预览结果{" "}
|
||||
<button type="button" onClick={onRetryFailure}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const isVideo = item.mode === "video";
|
||||
return <div className="creation-media-result">
|
||||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>;
|
||||
return (
|
||||
<div className="creation-media-result">
|
||||
{isVideo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="creation-video-result"
|
||||
onClick={() => {
|
||||
setPreviewType("video");
|
||||
setPreviewUrl(displayResultUrls[0]);
|
||||
}}
|
||||
aria-label="预览生成视频"
|
||||
>
|
||||
<video muted preload="metadata" src={displayResultUrls[0]} />
|
||||
<span>
|
||||
<Maximize2 />
|
||||
预览视频
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="creation-image-result-grid">
|
||||
{displayResultUrls.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
className="creation-image-result"
|
||||
onClick={() => {
|
||||
setPreviewType("image");
|
||||
setPreviewUrl(url);
|
||||
}}
|
||||
aria-label="预览生成图片"
|
||||
>
|
||||
<img src={url} alt="生成结果" />
|
||||
<span>
|
||||
<Maximize2 />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="creation-media-actions">
|
||||
<span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span>
|
||||
<Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>
|
||||
添加到画布
|
||||
</Button>
|
||||
{displayResultUrls.map((url, index) => (
|
||||
<a key={`${url}-download`} href={url} download>
|
||||
{displayResultUrls.length > 1 ? (
|
||||
`下载 ${index + 1}`
|
||||
) : (
|
||||
<>
|
||||
<Download />
|
||||
下载
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><WorkingDots dotSize={7} gap={3} minOpacity={0.3} /></span><span className="sr-only">{brandName}正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
|
||||
return (
|
||||
<div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite">
|
||||
<span className="creation-media-pending-icon">
|
||||
<WorkingDots dotSize={7} gap={3} minOpacity={0.3} />
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{brandName}正在生成{mode === "video" ? "视频" : "图像"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
||||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||||
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
|
||||
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
|
||||
})}</div>;
|
||||
return (
|
||||
<div className="creation-user-message-references" aria-label="本次引用">
|
||||
{references.map((reference) => {
|
||||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||||
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
|
||||
return (
|
||||
<span key={reference.id} className="creation-user-message-reference">
|
||||
{imageUrl && reference.kind === "video" ? (
|
||||
<video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} />
|
||||
) : imageUrl && reference.kind === "image" ? (
|
||||
<CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<Icon />
|
||||
)}
|
||||
<span>{reference.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
|
||||
if (type === "image") return <CanvasImagePreview src={url} alt="媒体预览" onClose={onClose} />;
|
||||
|
||||
return <AppModal flush open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal">
|
||||
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
|
||||
</AppModal>;
|
||||
return (
|
||||
<AppModal flush open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal">
|
||||
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
|
||||
item: CreationAttachment;
|
||||
onPreview: (type: "image" | "video", url: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
export function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { item: CreationAttachment; onPreview: (type: "image" | "video", url: string) => void; onRemove: (id: string) => void }) {
|
||||
const kind = creationAttachmentKind(item);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = (kind === "video" ? item.url : item.previewUrl) || "";
|
||||
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
|
||||
const previewUrl = kind === "image" ? imageUrl : url;
|
||||
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
|
||||
return <div className="creation-reference-card-content">
|
||||
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
|
||||
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
|
||||
</div>;
|
||||
const content =
|
||||
kind === "video" ? (
|
||||
<video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} />
|
||||
) : kind === "image" ? (
|
||||
<CachedResourceImage
|
||||
storageKey={item.storageKey}
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fallback={
|
||||
<span className="creation-chat-file-icon">
|
||||
<ImageIcon />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="creation-chat-file-icon">
|
||||
{kind === "audio" ? <Music2 /> : <FileText />}
|
||||
<em>{item.name}</em>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="creation-reference-card-content">
|
||||
{previewable ? (
|
||||
<button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>
|
||||
{content}
|
||||
<span aria-hidden="true">
|
||||
<Maximize2 />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="creation-reference-card-preview is-file" aria-label={item.name}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="creation-reference-card-remove"
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
onMouseDownCapture={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
aria-label={`移除 ${item.name}`}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type CreationThinking = { title: string; hint: string; steps: string[]; activity: string };
|
||||
|
||||
@@ -21,7 +21,6 @@ export function normalizeRawModelName(value: unknown) {
|
||||
return model && model !== "undefined" && model !== "null" ? model : "";
|
||||
}
|
||||
|
||||
|
||||
export function encodeChannelModel(channelId: string, model: string) {
|
||||
return `${channelId}${CHANNEL_MODEL_SEPARATOR}${model.trim()}`;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,20 @@ import { type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CapabilitySpec, PublicLogicalModelPriceTier } from "@/services/api/logical-models";
|
||||
import { type WorkflowFieldMapping, normalizeRunningHubCapability, normalizeRunningHubWorkflowKind, normalizeSavedWorkflowFields, mergeWorkflowFieldMappings, normalizeWorkflowFieldMappings } from "./config-workflow-fields";
|
||||
import { decodeChannelModel, modelOptionName, modelOptionsFromChannels, normalizeModelOptionValue, CHANNEL_MODEL_SEPARATOR, uniqueModelOptions, encodeChannelModel, hasSystemModelPrice, isChannelModelValue, modelDisplayName, modelIcon, modelOptionLabel } from "./config-model-options";
|
||||
import {
|
||||
decodeChannelModel,
|
||||
modelOptionName,
|
||||
modelOptionsFromChannels,
|
||||
normalizeModelOptionValue,
|
||||
CHANNEL_MODEL_SEPARATOR,
|
||||
uniqueModelOptions,
|
||||
encodeChannelModel,
|
||||
hasSystemModelPrice,
|
||||
isChannelModelValue,
|
||||
modelDisplayName,
|
||||
modelIcon,
|
||||
modelOptionLabel,
|
||||
} from "./config-model-options";
|
||||
import { normalizeRawModelName } from "./config-model-options";
|
||||
|
||||
export { CHANNEL_MODEL_SEPARATOR, normalizeRawModelName, uniqueModelOptions } from "./config-model-options";
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("creation library button", () => {
|
||||
const createSource = readCreateWorkspaceSource();
|
||||
|
||||
expect(assets).toContain("resolveResourceUrl(asset.data.storageKey");
|
||||
expect(createSource).toContain("<CachedResourceImage storageKey={item.storageKey}");
|
||||
expect(createSource.replace(/\s+/g, " ")).toContain("<CachedResourceImage storageKey={item.storageKey}");
|
||||
expect(createSource).toContain("resolveResourceUrl(item.storageKey, item.previewUrl)");
|
||||
});
|
||||
|
||||
|
||||
@@ -96,25 +96,27 @@ describe("模式接线", () => {
|
||||
});
|
||||
|
||||
test("骨骼/姿势入口只对演员开放,且由 bones 把关", () => {
|
||||
expect(workbench).toContain('{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>');
|
||||
const normalizedWorkbench = workbench.replace(/\s+/g, " ");
|
||||
expect(normalizedWorkbench).toContain('{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? ( <>');
|
||||
// motionClips 不得再作为放行条件:带动画的普通模型不是演员。
|
||||
expect(workbench).not.toContain('object.primitive === "character" || motionClips.length) ? <>');
|
||||
});
|
||||
|
||||
test("姿态模式提供全身与当前骨骼重置,不删除动画轨道", () => {
|
||||
const inspector = slice(workbench, "function ObjectInspector(", "function LightInspector(");
|
||||
expect(inspector).toContain('onClick={() => applyPose("stand")}>重置姿态</Button>');
|
||||
const normalizedInspector = inspector.replace(/\s+/g, " ");
|
||||
expect(normalizedInspector).toContain('onClick={() => applyPose("stand")}> 重置姿态 </Button>');
|
||||
expect(inspector).toContain("delete boneOverrides[selectedBoneId]");
|
||||
expect(inspector).toContain(">重置当前骨骼</Button>");
|
||||
expect(normalizedInspector).toContain("> 重置当前骨骼 </Button>");
|
||||
expect(inspector).not.toContain("boneTracks: []");
|
||||
});
|
||||
|
||||
test("动作片段与骨骼入口解耦:任何带 Clip 的对象都能调播放速度/循环", () => {
|
||||
expect(workbench).toContain('{motionClips.length ? <><Field label="动作片段">');
|
||||
expect(workbench.replace(/\s+/g, " ")).toContain('{motionClips.length ? ( <> <Field label="动作片段">');
|
||||
});
|
||||
|
||||
test("关键帧入口由 keyframes 把关", () => {
|
||||
expect(workbench).toContain("{capabilities.keyframes ? <>");
|
||||
expect(workbench.replace(/\s+/g, " ")).toContain("{capabilities.keyframes ? ( <>");
|
||||
});
|
||||
|
||||
test("渲染视图下拉按当前模式过滤,而不是写死五项", () => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { applyFrameDrop, buildCanvasFrameDropIndex, findFrameDropTargetFromIndex } from "@/lib/canvas/canvas-frame";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
const frame = { id: "f", type: CanvasNodeType.Frame, title: "f", position: { x: 0, y: 0 }, width: 760, height: 520, metadata: { frame: { collapsed: false, expandedWidth: 760, expandedHeight: 520 } } } as any;
|
||||
for (const t of [CanvasNodeType.Text, CanvasNodeType.Markdown, CanvasNodeType.Audio, CanvasNodeType.Image]) {
|
||||
test(t, () => {
|
||||
const n = { id: "n", type: t, title: "n", position: { x: 100, y: 100 }, width: 340, height: 240, metadata: {} } as any;
|
||||
const target = findFrameDropTargetFromIndex(buildCanvasFrameDropIndex([frame, n]), [n], new Set(["n"]), { x: 0, y: 0 });
|
||||
console.log(t, target, applyFrameDrop([frame, n], new Set(["n"]), target).find((x) => x.id === "n")?.parentId);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user