229 lines
7.2 KiB
TypeScript
229 lines
7.2 KiB
TypeScript
import type { DirectorCamera, DirectorVector } from "./director-scene";
|
||
export type DirectorPose = Pick<DirectorCamera, "position" | "target" | "fov">;
|
||
export function mergeDirectorCameraPose(
|
||
camera: DirectorCamera,
|
||
pose: Partial<DirectorPose>,
|
||
): DirectorCamera {
|
||
return {
|
||
...camera,
|
||
position: pose.position ?? camera.position,
|
||
target: pose.target ?? camera.target,
|
||
fov: pose.fov ?? camera.fov,
|
||
};
|
||
}
|
||
export type DirectorKeyframe = DirectorPose & { id: string; time: number };
|
||
export type DirectorMotion = {
|
||
duration: number;
|
||
easing: "linear" | "smooth";
|
||
loop: boolean;
|
||
keyframes: DirectorKeyframe[];
|
||
};
|
||
export const defaultDirectorMotion = (): DirectorMotion => ({
|
||
duration: 5,
|
||
easing: "smooth",
|
||
loop: false,
|
||
keyframes: [],
|
||
});
|
||
const vector = (v: unknown): v is DirectorVector =>
|
||
Array.isArray(v) &&
|
||
v.length === 3 &&
|
||
v.every(
|
||
(n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 10000,
|
||
);
|
||
export function validDirectorPose(p: DirectorPose) {
|
||
return (
|
||
!!p &&
|
||
vector(p.position) &&
|
||
vector(p.target) &&
|
||
Number.isFinite(p.fov) &&
|
||
p.fov >= 15 &&
|
||
p.fov <= 90 &&
|
||
Math.hypot(...p.position.map((n, i) => n - p.target[i])) > 0.001
|
||
);
|
||
}
|
||
export function readDirectorMotion(input: unknown): DirectorMotion {
|
||
const v = (input || {}) as Partial<DirectorMotion>;
|
||
const duration = Number.isFinite(v.duration)
|
||
? Math.max(2, Math.min(15, Number(v.duration)))
|
||
: 5;
|
||
const seen = new Set<string>();
|
||
const times = new Set<number>();
|
||
const keyframes = (Array.isArray(v.keyframes) ? v.keyframes : [])
|
||
.filter((k) => {
|
||
if (
|
||
!k ||
|
||
typeof k.id !== "string" ||
|
||
seen.has(k.id) ||
|
||
!Number.isFinite(k.time) ||
|
||
k.time < 0 ||
|
||
k.time > duration ||
|
||
times.has(k.time) ||
|
||
!validDirectorPose(k)
|
||
)
|
||
return false;
|
||
seen.add(k.id);
|
||
times.add(k.time);
|
||
return true;
|
||
})
|
||
.slice(0, 100)
|
||
.map((k) => ({
|
||
id: k.id,
|
||
time: k.time,
|
||
position: [...k.position] as DirectorVector,
|
||
target: [...k.target] as DirectorVector,
|
||
fov: k.fov,
|
||
}))
|
||
.sort((a, b) => a.time - b.time);
|
||
return {
|
||
duration,
|
||
easing: v.easing === "linear" ? "linear" : "smooth",
|
||
loop: v.loop === true,
|
||
keyframes,
|
||
};
|
||
}
|
||
const lerpVector = (
|
||
a: DirectorVector,
|
||
b: DirectorVector,
|
||
t: number,
|
||
): DirectorVector => a.map((n, i) => n + (b[i] - n) * t) as DirectorVector;
|
||
export function sampleDirectorMotion(
|
||
m: DirectorMotion,
|
||
time: number,
|
||
): DirectorPose | null {
|
||
const keys = m.keyframes;
|
||
if (!keys.length) return null;
|
||
const t = Number.isFinite(time) ? Math.max(0, Math.min(m.duration, time)) : 0;
|
||
const right = keys.findIndex((k) => k.time > t);
|
||
const a = right < 0 ? keys[keys.length - 1] : keys[Math.max(0, right - 1)];
|
||
const b = right < 0 ? a : keys[right];
|
||
let u =
|
||
a === b ? 0 : Math.max(0, Math.min(1, (t - a.time) / (b.time - a.time)));
|
||
if (m.easing === "smooth") u = u * u * (3 - 2 * u);
|
||
const pose = {
|
||
position: lerpVector(a.position, b.position, u),
|
||
target: lerpVector(a.target, b.target, u),
|
||
fov: a.fov + (b.fov - a.fov) * u,
|
||
};
|
||
// A path crossing its look-at target has no defined viewing direction.
|
||
if (!validDirectorPose(pose)) {
|
||
const direction = a.position.map((n, i) => n - a.target[i]);
|
||
const length = Math.hypot(...direction);
|
||
pose.target = pose.position.map(
|
||
(n, i) => n - (direction[i] / length) * 0.01,
|
||
) as DirectorVector;
|
||
}
|
||
return pose;
|
||
}
|
||
export function resizeDirectorMotion(
|
||
m: DirectorMotion,
|
||
duration: number,
|
||
): DirectorMotion {
|
||
const next = Math.max(2, Math.min(15, duration));
|
||
return {
|
||
...m,
|
||
duration: next,
|
||
keyframes: m.keyframes.map((k) => ({
|
||
...k,
|
||
time: (k.time / m.duration) * next,
|
||
})),
|
||
};
|
||
}
|
||
export function presetDirectorMotion(
|
||
pose: DirectorPose,
|
||
kind: "push" | "pull" | "orbit" | "pan",
|
||
duration = 5,
|
||
): DirectorMotion {
|
||
const m = { ...defaultDirectorMotion(), duration };
|
||
const offset = pose.position.map(
|
||
(n, i) => n - pose.target[i],
|
||
) as DirectorVector;
|
||
const count = kind === "orbit" ? 9 : 2;
|
||
m.keyframes = Array.from({ length: count }, (_, i) => {
|
||
const u = i / (count - 1);
|
||
let position: DirectorVector = [...pose.position],
|
||
target: DirectorVector = [...pose.target];
|
||
if (kind === "push" || kind === "pull")
|
||
position = pose.target.map(
|
||
(n, j) => n + offset[j] * (1 + u * (kind === "push" ? -0.5 : 0.5)),
|
||
) as DirectorVector;
|
||
if (kind === "orbit") {
|
||
const angle = (u * Math.PI) / 2;
|
||
position = [
|
||
pose.target[0] +
|
||
offset[0] * Math.cos(angle) +
|
||
offset[2] * Math.sin(angle),
|
||
pose.position[1],
|
||
pose.target[2] -
|
||
offset[0] * Math.sin(angle) +
|
||
offset[2] * Math.cos(angle),
|
||
];
|
||
}
|
||
if (kind === "pan") {
|
||
const length = Math.hypot(offset[0], offset[2]) || 1;
|
||
const shift: DirectorVector = [
|
||
(offset[2] / length) * 2 * u,
|
||
0,
|
||
(-offset[0] / length) * 2 * u,
|
||
];
|
||
position = position.map((n, j) => n + shift[j]) as DirectorVector;
|
||
target = target.map((n, j) => n + shift[j]) as DirectorVector;
|
||
}
|
||
return {
|
||
id: `${kind}-${i}`,
|
||
time: u * duration,
|
||
position,
|
||
target,
|
||
fov: pose.fov,
|
||
};
|
||
});
|
||
if (kind === "orbit") m.easing = "linear";
|
||
return m;
|
||
}
|
||
export function directorMotionPrompt(m: DirectorMotion) {
|
||
if (m.keyframes.length < 2) return "";
|
||
// Keep the model prompt compact; the full trajectory remains in the canvas data.
|
||
const keys =
|
||
m.keyframes.length <= 9
|
||
? m.keyframes
|
||
: Array.from({ length: 9 }, (_, i) => ({
|
||
time: (m.duration * i) / 8,
|
||
...sampleDirectorMotion(m, (m.duration * i) / 8)!,
|
||
}));
|
||
const lines = keys.slice(1).map((b, i) => {
|
||
const a = keys[i],
|
||
av = a.position.map((n, j) => n - a.target[j]),
|
||
bv = b.position.map((n, j) => n - b.target[j]);
|
||
const ar = Math.hypot(...av),
|
||
br = Math.hypot(...bv),
|
||
angle =
|
||
(Math.acos(
|
||
Math.max(
|
||
-1,
|
||
Math.min(
|
||
1,
|
||
av.reduce((sum, n, j) => sum + n * bv[j], 0) / (ar * br),
|
||
),
|
||
),
|
||
) *
|
||
180) /
|
||
Math.PI;
|
||
const descriptions: string[] = [];
|
||
if (angle > 2)
|
||
descriptions.push(`围绕注视主体转动约 ${Math.round(angle)} 度`);
|
||
if (br < ar * 0.98) descriptions.push("向主体推进");
|
||
else if (br > ar * 1.02) descriptions.push("远离主体拉远");
|
||
if (Math.hypot(...b.target.map((n, j) => n - a.target[j])) > 0.05)
|
||
descriptions.push("注视点随镜头平移");
|
||
if (Math.abs(b.position[1] - a.position[1]) > 0.05)
|
||
descriptions.push(
|
||
b.position[1] > a.position[1] ? "相机升高" : "相机降低",
|
||
);
|
||
if (Math.abs(b.fov - a.fov) > 0.5)
|
||
descriptions.push(
|
||
`视角从 ${a.fov.toFixed(0)}° 变至 ${b.fov.toFixed(0)}°`,
|
||
);
|
||
return `${a.time.toFixed(2)}–${b.time.toFixed(2)}秒:${descriptions.join(",") || "保持机位和构图"}。`;
|
||
});
|
||
return `单一连续镜头,不切镜,总时长 ${m.duration} 秒;镜头${m.easing === "smooth" ? "平滑缓入缓出" : "匀速"}运动。\n${lines.join("\n")}\n保持参考图中的主体和场景一致,镜头连续运动。`;
|
||
}
|