all

视频

视频

视频

视频

视频

init
This commit is contained in:
LKH committed 2026-07-29 09:17:00 +08:00
commit 3b053c714c
128 files changed
+31559

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { afterEach, expect, it, vi } from "vitest";
import {
clearViewportCaptureHandler,
requestViewportCapture,
setViewportCaptureHandler,
} from "./captureBridge";
afterEach(() => {
clearViewportCaptureHandler();
});
it("forwards capture requests to the active canvas handler", async () => {
const handler = vi.fn(async () => [
{
label: "当前视角",
dataUrl: "data:image/png;base64,demo",
meta: {
mode: "director" as const,
cameraId: null,
fov: 50,
position: [0, 2.2, 9] as [number, number, number],
target: [0, 1.2, 0] as [number, number, number],
},
},
]);
setViewportCaptureHandler(handler);
const results = await requestViewportCapture({
preset: "current",
source: "capture-panel",
});
expect(handler).toHaveBeenCalledWith({
preset: "current",
source: "capture-panel",
});
expect(results[0]?.dataUrl).toContain("data:image/png");
});
it("throws a clear error when the viewport capture handler is missing", async () => {
await expect(
requestViewportCapture({
preset: "current",
source: "camera-panel",
cameraId: "cam_1",
})
).rejects.toThrow("Viewport capture handler is not registered");
});
+29
View File
@@ -0,0 +1,29 @@
import type { ScreenshotResult } from "./screenshotExport";
export type ViewportCapturePreset = "current" | "four" | "twelve";
export interface ViewportCaptureRequest {
preset: ViewportCapturePreset;
source: "capture-panel" | "camera-panel";
cameraId?: string | null;
}
export type ViewportCaptureHandler = (request: ViewportCaptureRequest) => Promise<ScreenshotResult[]>;
let viewportCaptureHandler: ViewportCaptureHandler | null = null;
export function setViewportCaptureHandler(handler: ViewportCaptureHandler) {
viewportCaptureHandler = handler;
}
export function clearViewportCaptureHandler() {
viewportCaptureHandler = null;
}
export async function requestViewportCapture(request: ViewportCaptureRequest) {
if (!viewportCaptureHandler) {
throw new Error("Viewport capture handler is not registered");
}
return viewportCaptureHandler(request);
}
+5
View File
@@ -0,0 +1,5 @@
import type { DirectorProject } from "../schema/directorProject";
export function serializeProject(project: DirectorProject) {
return JSON.stringify(project, null, 2);
}
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
clearDirectorDeskHostBridge,
initDirectorDeskHostBridge,
} from "./hostBridge";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
function createMemoryStorage(): Storage {
const storage = new Map<string, string>();
return {
get length() {
return storage.size;
},
clear: () => storage.clear(),
getItem: (key) => storage.get(key) ?? null,
key: (index) => Array.from(storage.keys())[index] ?? null,
removeItem: (key) => {
storage.delete(key);
},
setItem: (key, value) => {
storage.set(key, String(value));
},
};
}
beforeEach(() => {
vi.stubGlobal("localStorage", createMemoryStorage());
document.documentElement.classList.remove("dark");
delete document.documentElement.dataset.theme;
useDirectorStore.getState().openScopedScene(null);
useDirectorStore.setState({
...useDirectorStore.getState(),
...createInitialDirectorState(),
});
});
afterEach(() => {
clearDirectorDeskHostBridge();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("imports a host panorama message into the director store", () => {
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-panorama",
payload: {
edgeId: "edge-image-director",
sourceNodeId: "node_image",
imageUrl: "data:image/png;base64,panorama",
fileName: "画布图片.png",
},
},
origin: window.location.origin,
})
);
const state = useDirectorStore.getState();
const panoramaAsset = state.project.assets.find(
(asset) => asset.id === state.project.panoramaAssetId
);
expect(panoramaAsset).toMatchObject({
kind: "panorama",
sourceType: "image",
fileName: "画布图片.png",
name: "画布图片.png",
url: "data:image/png;base64,panorama",
});
});
it("switches director store persistence when the host sends a card session", () => {
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_a",
},
},
origin: window.location.origin,
})
);
useDirectorStore.getState().updateScene({ backgroundColor: "#151515" });
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_b",
},
},
origin: window.location.origin,
})
);
expect(useDirectorStore.getState().project.scene.backgroundColor).toBe("#000000");
useDirectorStore.getState().updateScene({ backgroundColor: "#303640" });
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_a",
},
},
origin: window.location.origin,
})
);
expect(useDirectorStore.getState().project.scene.backgroundColor).toBe("#151515");
});
it("applies the light theme sent by the host session to the director desk document", () => {
document.documentElement.classList.add("dark");
document.documentElement.dataset.theme = "dark";
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_light",
theme: "light",
},
},
origin: window.location.origin,
})
);
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("applies the dark theme sent by the host session to the director desk document", () => {
document.documentElement.dataset.theme = "light";
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_dark",
theme: "dark",
},
},
origin: window.location.origin,
})
);
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("ignores a host panorama message without an image url", () => {
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-panorama",
payload: {
edgeId: "edge-image-director",
sourceNodeId: "node_image",
},
},
origin: window.location.origin,
})
);
expect(useDirectorStore.getState().project.panoramaAssetId).toBeNull();
});
it("notifies the host canvas when a host-connected panorama is removed", () => {
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined);
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-panorama",
payload: {
edgeId: "edge-image-director",
sourceNodeId: "node_image",
imageUrl: "data:image/png;base64,panorama",
fileName: "画布图片.png",
},
},
origin: window.location.origin,
})
);
useDirectorStore.getState().removePanoramaAsset();
expect(postMessage).toHaveBeenCalledWith(
{
type: "storyai:director-desk-panorama-removed",
payload: {
edgeId: "edge-image-director",
sourceNodeId: "node_image",
},
},
window.location.origin
);
});
it("does not notify the host canvas when changing to a different card session clears the current panorama", () => {
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined);
initDirectorDeskHostBridge();
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_a",
},
},
origin: window.location.origin,
})
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-panorama",
payload: {
edgeId: "edge-image-director-a",
sourceNodeId: "node_image_a",
imageUrl: "data:image/png;base64,panorama-a",
fileName: "画布图片A.png",
},
},
origin: window.location.origin,
})
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "storyai:director-desk-session",
payload: {
instanceId: "node_director_b",
},
},
origin: window.location.origin,
})
);
expect(postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "storyai:director-desk-panorama-removed",
}),
window.location.origin
);
});
+200
View File
@@ -0,0 +1,200 @@
import { useDirectorStore } from "../store/directorStore";
interface HostPanoramaPayload {
edgeId?: unknown;
sourceNodeId?: unknown;
imageUrl?: unknown;
fileName?: unknown;
}
interface HostSessionPayload {
instanceId?: unknown;
theme?: unknown;
}
export interface HostCaptureItemPayload {
dataUrl?: unknown;
fileName?: unknown;
}
export interface HostCaptureBatchPayload {
captures?: HostCaptureItemPayload[];
}
interface HostConnectedPanorama {
edgeId: string;
sourceNodeId: string;
}
let initialized = false;
let hostConnectedPanorama: HostConnectedPanorama | null = null;
let removeUnsubscribe: (() => void) | null = null;
let suppressNextPanoramaRemovalNotice = false;
function normalizeString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getHostOrigin() {
return window.location.origin;
}
function normalizeTheme(value: unknown): "dark" | "light" | null {
return value === "light" || value === "dark" ? value : null;
}
function applyDirectorDeskTheme(theme: "dark" | "light") {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
}
function getInitialHostTheme() {
try {
return normalizeTheme(new URLSearchParams(window.location.search).get("theme"));
} catch {
return null;
}
}
function notifyPanoramaRemoved() {
if (!hostConnectedPanorama) {
return;
}
window.parent?.postMessage(
{
type: "storyai:director-desk-panorama-removed",
payload: hostConnectedPanorama,
},
getHostOrigin()
);
hostConnectedPanorama = null;
}
function subscribeToPanoramaRemoval() {
if (removeUnsubscribe) {
return;
}
let previousPanoramaAssetId = useDirectorStore.getState().project.panoramaAssetId;
removeUnsubscribe = useDirectorStore.subscribe((state) => {
const nextPanoramaAssetId = state.project.panoramaAssetId;
if (previousPanoramaAssetId && !nextPanoramaAssetId) {
if (suppressNextPanoramaRemovalNotice) {
suppressNextPanoramaRemovalNotice = false;
hostConnectedPanorama = null;
} else {
notifyPanoramaRemoved();
}
}
previousPanoramaAssetId = nextPanoramaAssetId;
});
}
function importHostPanorama(payload: HostPanoramaPayload) {
const imageUrl = normalizeString(payload.imageUrl);
if (!imageUrl) {
return;
}
const fileName = normalizeString(payload.fileName) || "画布全景图.png";
const edgeId = normalizeString(payload.edgeId);
const sourceNodeId = normalizeString(payload.sourceNodeId);
hostConnectedPanorama = edgeId && sourceNodeId ? { edgeId, sourceNodeId } : null;
useDirectorStore.getState().addImportedAsset({
kind: "panorama",
name: fileName,
fileName,
url: imageUrl,
projectionMode: "backdrop",
});
}
function openHostSession(payload: HostSessionPayload) {
const instanceId = normalizeString(payload.instanceId);
const theme = normalizeTheme(payload.theme);
if (theme) {
applyDirectorDeskTheme(theme);
}
suppressNextPanoramaRemovalNotice = Boolean(useDirectorStore.getState().project.panoramaAssetId);
useDirectorStore.getState().openScopedScene(instanceId || null);
suppressNextPanoramaRemovalNotice = false;
hostConnectedPanorama = null;
}
export function postDirectorDeskCapturesToHost(
captures: Array<{
dataUrl: string;
fileName?: string;
}>
) {
const normalizedCaptures = captures
.map((capture, index) => {
const dataUrl = normalizeString(capture.dataUrl);
if (!dataUrl) {
return null;
}
return {
dataUrl,
fileName: normalizeString(capture.fileName) || `director-desk-capture-${index + 1}.png`,
};
})
.filter((capture): capture is { dataUrl: string; fileName: string } => Boolean(capture));
if (normalizedCaptures.length === 0) {
return;
}
window.parent?.postMessage(
{
type: "storyai:director-desk-captures-sent",
payload: {
captures: normalizedCaptures,
},
},
getHostOrigin()
);
}
function handleHostMessage(event: MessageEvent) {
if (event.origin !== getHostOrigin()) {
return;
}
if (event.data?.type === "storyai:director-desk-session") {
openHostSession((event.data.payload || {}) as HostSessionPayload);
return;
}
if (event.data?.type === "storyai:director-desk-panorama") {
importHostPanorama((event.data.payload || {}) as HostPanoramaPayload);
}
}
export function initDirectorDeskHostBridge() {
if (initialized) {
return;
}
initialized = true;
applyDirectorDeskTheme(getInitialHostTheme() ?? "dark");
window.addEventListener("message", handleHostMessage);
subscribeToPanoramaRemoval();
}
export function clearDirectorDeskHostBridge() {
if (!initialized) {
return;
}
initialized = false;
hostConnectedPanorama = null;
suppressNextPanoramaRemovalNotice = false;
window.removeEventListener("message", handleHostMessage);
removeUnsubscribe?.();
removeUnsubscribe = null;
}
+5
View File
@@ -0,0 +1,5 @@
import type { DirectorProject } from "../schema/directorProject";
export function parseProject(json: string): DirectorProject {
return JSON.parse(json) as DirectorProject;
}
+30
View File
@@ -0,0 +1,30 @@
import { parseProject } from "./importProjectJson";
import { buildScreenshotMeta } from "./screenshotExport";
import { serializeProject } from "./exportProjectJson";
import { createDefaultDirectorProject } from "../store/directorStore";
it("captures the required metadata for camera-mode screenshots", () => {
expect(
buildScreenshotMeta({
mode: "camera",
cameraId: "cam_1",
fov: 50,
position: [0, 2.2, 9],
target: [0, 1.2, 0],
})
).toEqual({
mode: "camera",
cameraId: "cam_1",
fov: 50,
position: [0, 2.2, 9],
target: [0, 1.2, 0],
});
});
it("round-trips the project JSON without losing objects or cameras", () => {
const json = serializeProject(createDefaultDirectorProject());
const project = parseProject(json);
expect(project.cameras[0].name).toBe("机位01");
expect(project.objects.some((item) => item.kind === "character")).toBe(true);
});
+39
View File
@@ -0,0 +1,39 @@
export interface ScreenshotMeta {
mode: "director" | "camera";
cameraId: string | null;
fov: number;
position: [number, number, number];
target: [number, number, number];
}
export interface ScreenshotResult {
label: string;
dataUrl: string;
meta: ScreenshotMeta;
}
export function buildScreenshotMeta(input: ScreenshotMeta) {
return input;
}
export function buildCaptureFileName(result: ScreenshotResult, index = 0) {
const labelSlug = result.label.replace(/\s+/g, "-");
const cameraSuffix = result.meta.cameraId ? `-${result.meta.cameraId}` : "";
return `storyai-director-desk-${result.meta.mode}${cameraSuffix}-${labelSlug}-${index + 1}.png`;
}
export function downloadDataUrl(dataUrl: string, fileName: string) {
const anchor = document.createElement("a");
anchor.href = dataUrl;
anchor.download = fileName;
anchor.rel = "noopener";
anchor.click();
}
export function downloadCaptureResults(results: ScreenshotResult[]) {
results.forEach((result, index) => {
downloadDataUrl(result.dataUrl, buildCaptureFileName(result, index));
});
return results.length;
}
+120
View File
@@ -0,0 +1,120 @@
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
export type VideoExportView = "director" | "camera";
function pickMimeType(): { mimeType: string; ext: string } {
if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" };
const candidates = [
{ mimeType: "video/mp4;codecs=avc1", ext: "mp4" },
{ mimeType: "video/webm;codecs=vp9", ext: "webm" },
{ mimeType: "video/webm;codecs=vp8", ext: "webm" },
{ mimeType: "video/webm", ext: "webm" },
];
return (
candidates.find((item) => {
try {
return MediaRecorder.isTypeSupported(item.mimeType);
} catch {
return false;
}
}) ?? { mimeType: "", ext: "webm" }
);
}
function findViewportCanvas(): HTMLCanvasElement | null {
return document.querySelector<HTMLCanvasElement>(".director-canvas canvas");
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function waitNextFrame(): Promise<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
}
/**
* 录制视口画面为视频:
* - 切到指定视角(director=上帝视角 / camera=机位视角)
* - 从 playhead 0 播放到时间轴末尾(stopAtEnd,不循环)
* - 优先 MP4(Chrome 120+ / Safari),不支持时回退 WebM(Chrome / Firefox)
* - 录完自动下载
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
const canvas = findViewportCanvas();
if (!canvas) throw new Error("找不到视口画布");
if (typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频录制(MediaRecorder),请用 Chrome/Edge/Firefox");
}
const { mimeType, ext } = pickMimeType();
const fps = 30;
const stream = canvas.captureStream(fps);
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunks.push(event.data);
};
const directorStore = useDirectorStore.getState();
const duration = directorStore.project.timeline.duration;
// 切视角 + 复位指针 + 录制模式(到末尾停住不循环)
directorStore.setViewMode(view);
const playback = usePlaybackStore.getState();
playback.setStopAtEnd(true);
playback.setPlayhead(0);
const stopped = new Promise<void>((resolve) => {
recorder.onstop = () => resolve();
});
recorder.start();
// 等两帧让视角切换 + 首帧渲染,避免录到上一视角的残影
await waitNextFrame();
// 监听播放结束(stopAtEnd → isPlaying 置 false)→ 停止录制
const unsubscribe = usePlaybackStore.subscribe((state) => {
if (!state.isPlaying) {
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
/* ignore */
}
}
});
usePlaybackStore.getState().setPlaying(true);
// 保底:duration + 余量后强制停(防止 advance 因帧率/时序没准确触发)
const fallbackTimer = window.setTimeout(() => {
try {
if (recorder.state !== "inactive") recorder.stop();
} catch {
/* ignore */
}
}, duration * 1000 + 600);
await stopped;
clearTimeout(fallbackTimer);
unsubscribe();
usePlaybackStore.getState().setStopAtEnd(false);
usePlaybackStore.getState().setPlaying(false);
const viewLabel = view === "director" ? "上帝视角" : "机位视角";
const blob = new Blob(chunks, { type: mimeType || "video/webm" });
downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.${ext}`);
}