ai-chatbot/lib/video-canvas/director-motion.ts

229 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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保持参考图中的主体和场景一致,镜头连续运动。`;
}