fix(web): Web 检查 - 格式化拆分模块并修正源码断言

拆分出的前端模块未按 Prettier 格式化导致 CI 格式检查失败;源码断言改为忽略空白差异,导演台引导组件恢复别名导入。同时移除误提交的分析草稿、pnpm 锁文件和调试测试。
This commit is contained in:
ddcat666
2026-09-30 10:32:17 +08:00
parent b1d854ee51
commit fe17e3747a
22 changed files with 1197 additions and 2137 deletions

View File

@@ -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 修改提示词" : "正在按阶段生成批改报告";

View File

@@ -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,

View File

@@ -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>
));

View File

@@ -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;

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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,