init
all 视频 视频 视频 视频 视频 init
This commit is contained in:
commit
3b053c714c
128 files changed
+31559
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DirectorProject } from "../schema/directorProject";
|
||||
|
||||
export function serializeProject(project: DirectorProject) {
|
||||
return JSON.stringify(project, null, 2);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DirectorProject } from "../schema/directorProject";
|
||||
|
||||
export function parseProject(json: string): DirectorProject {
|
||||
return JSON.parse(json) as DirectorProject;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user