perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)
Merge origin/main at fb089b2 and retain canvas media performance, player layout, and interaction updates.
Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
151
web/test/canvas-audio-playback.test.ts
Normal file
151
web/test/canvas-audio-playback.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasAudioPlayback, getCanvasAudioPlaybackSnapshot, seekCanvasAudio, subscribeCanvasAudioNode, toggleCanvasAudio } from "../src/services/canvas-audio-playback";
|
||||
|
||||
type AudioEventName = "loadedmetadata" | "timeupdate" | "playing" | "pause" | "ended" | "error" | "durationchange";
|
||||
|
||||
class FakeAudio {
|
||||
static instances: FakeAudio[] = [];
|
||||
currentTime = 0;
|
||||
duration = 3;
|
||||
preload = "";
|
||||
src = "";
|
||||
private readonly handlers = new Map<AudioEventName, Set<() => void>>();
|
||||
|
||||
constructor() {
|
||||
FakeAudio.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: AudioEventName, listener: () => void) {
|
||||
const listeners = this.handlers.get(type) || new Set<() => void>();
|
||||
listeners.add(listener);
|
||||
this.handlers.set(type, listeners);
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
if (name === "src") this.src = "";
|
||||
}
|
||||
|
||||
load() {
|
||||
if (this.src) this.emit("loadedmetadata");
|
||||
}
|
||||
|
||||
async play() {
|
||||
this.emit("playing");
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.emit("pause");
|
||||
}
|
||||
|
||||
emit(type: AudioEventName) {
|
||||
this.handlers.get(type)?.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
const originalAudio = globalThis.Audio;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.Audio = FakeAudio as unknown as typeof Audio;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
canvasAudioPlayback.stop();
|
||||
FakeAudio.instances = [];
|
||||
globalThis.Audio = originalAudio;
|
||||
});
|
||||
|
||||
describe("canvas audio playback", () => {
|
||||
test("creates the audio element only after play and reuses one instance", async () => {
|
||||
expect(FakeAudio.instances).toHaveLength(0);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("idle");
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
|
||||
expect(FakeAudio.instances).toHaveLength(1);
|
||||
expect(FakeAudio.instances[0]?.src).toContain("data:audio/mpeg");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("playing");
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-b", content: "data:audio/mpeg;base64,b" });
|
||||
|
||||
expect(FakeAudio.instances).toHaveLength(1);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("idle");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-b").phase).toBe("playing");
|
||||
});
|
||||
|
||||
test("notifies only the active node for playback progress", async () => {
|
||||
canvasAudioPlayback.stop();
|
||||
let audioUpdates = 0;
|
||||
let otherUpdates = 0;
|
||||
const unsubscribeAudio = subscribeCanvasAudioNode("audio-a", () => {
|
||||
audioUpdates += 1;
|
||||
});
|
||||
const unsubscribeOther = subscribeCanvasAudioNode("audio-b", () => {
|
||||
otherUpdates += 1;
|
||||
});
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
const fakeAudio = FakeAudio.instances[0];
|
||||
if (!fakeAudio) throw new Error("fake audio was not created");
|
||||
const updatesBeforeProgress = audioUpdates;
|
||||
fakeAudio.currentTime = 1.25;
|
||||
fakeAudio.emit("timeupdate");
|
||||
|
||||
expect(audioUpdates).toBeGreaterThan(updatesBeforeProgress);
|
||||
expect(otherUpdates).toBe(0);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").currentTimeMs).toBe(1250);
|
||||
|
||||
unsubscribeAudio();
|
||||
unsubscribeOther();
|
||||
});
|
||||
|
||||
test("allows seeking before the first play without autoplaying", async () => {
|
||||
canvasAudioPlayback.stop();
|
||||
const instancesBeforeSeek = FakeAudio.instances.length;
|
||||
|
||||
await seekCanvasAudio("audio-seek", 1200, { nodeId: "audio-seek", content: "data:audio/mpeg;base64,seek", durationMs: 3000 });
|
||||
|
||||
expect(FakeAudio.instances).toHaveLength(instancesBeforeSeek);
|
||||
expect(FakeAudio.instances.at(-1)?.currentTime).toBe(1.2);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-seek").phase).toBe("paused");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-seek").currentTimeMs).toBe(1200);
|
||||
});
|
||||
|
||||
test("supports pause, seek, and replay after ending", async () => {
|
||||
canvasAudioPlayback.stop();
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
const fakeAudio = FakeAudio.instances[0];
|
||||
if (!fakeAudio) throw new Error("fake audio was not created");
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("paused");
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
seekCanvasAudio("audio-a", 1500);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").currentTimeMs).toBe(1500);
|
||||
|
||||
fakeAudio.emit("ended");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("ended");
|
||||
|
||||
await seekCanvasAudio("audio-a", 1200);
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("paused");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").currentTimeMs).toBe(1200);
|
||||
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("playing");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").currentTimeMs).toBe(1200);
|
||||
});
|
||||
|
||||
test("resets the previous node when seeking another audio", async () => {
|
||||
canvasAudioPlayback.stop();
|
||||
await toggleCanvasAudio({ nodeId: "audio-a", content: "data:audio/mpeg;base64,a" });
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("playing");
|
||||
|
||||
await seekCanvasAudio("audio-b", 800, { nodeId: "audio-b", content: "data:audio/mpeg;base64,b", durationMs: 3000 });
|
||||
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-a").phase).toBe("idle");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-b").phase).toBe("paused");
|
||||
expect(getCanvasAudioPlaybackSnapshot("audio-b").currentTimeMs).toBe(800);
|
||||
expect(FakeAudio.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { defaultConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -92,6 +93,35 @@ describe("planBatchConnections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas connection anchors", () => {
|
||||
it("keeps ordinary node edges centered even when legacy ratios exist", () => {
|
||||
const connection: CanvasConnection = {
|
||||
id: "ratio-test",
|
||||
fromNodeId: "text-a",
|
||||
toNodeId: "text-b",
|
||||
fromAnchorRatio: 0.2,
|
||||
toAnchorRatio: 0.8,
|
||||
};
|
||||
const result = canvasConnectionPath(connection, nodes[0], nodes[1]);
|
||||
|
||||
expect(result.startY).toBe(90);
|
||||
expect(result.endY).toBe(310);
|
||||
});
|
||||
|
||||
it("keeps storyboard row handles positioned independently from the node center", () => {
|
||||
const connection: CanvasConnection = {
|
||||
id: "storyboard-row-test",
|
||||
fromNodeId: "text-a",
|
||||
toNodeId: "script",
|
||||
toHandleId: "row:row-1",
|
||||
};
|
||||
const result = canvasConnectionPath(connection, nodes[0], nodes[4]);
|
||||
|
||||
expect(result.startY).toBe(90);
|
||||
expect(result.endY).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBatchConnectionCreateRequest", () => {
|
||||
it("keeps unique eligible sources and uses the first one for the pending connection", () => {
|
||||
const request = buildBatchConnectionCreateRequest(["frame", "text-a", "text-a", "text-b"], nodes, { x: 900, y: 240 });
|
||||
|
||||
15
web/test/canvas-cross-browser-positioning.test.ts
Normal file
15
web/test/canvas-cross-browser-positioning.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const stylesSource = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
describe("canvas cross-browser positioning", () => {
|
||||
test("uses standard transforms for the committed raster layer", () => {
|
||||
const rasterLayer = stylesSource.match(/\.canvas-world-raster-layer\s*\{[\s\S]*?\}/)?.[0] || "";
|
||||
expect(rasterLayer).toContain("transform: scale(var(--canvas-committed-scale))");
|
||||
expect(rasterLayer).toContain("transform-origin: 0 0");
|
||||
expect(rasterLayer).not.toContain("zoom:");
|
||||
});
|
||||
});
|
||||
285
web/test/canvas-media-performance.test.ts
Normal file
285
web/test/canvas-media-performance.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveActiveCanvasMediaNodeId } from "../src/lib/canvas/canvas-performance-mode";
|
||||
import { videoMetadata } from "../src/lib/canvas/canvas-generation-task-sync";
|
||||
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "../src/lib/canvas/canvas-media-preview";
|
||||
import { collectImageStorageKeys } from "../src/services/image-storage";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
import { detectVideoAudioTrack, detectVideoAudioTrackFromBlob, detectVideoAudioTrackFromUrl } from "../src/lib/video-poster";
|
||||
|
||||
const canvasNodeContentSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-content.tsx"), "utf8");
|
||||
const canvasAudioPlayerSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-audio-player.tsx"), "utf8");
|
||||
const canvasMentionSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-resource-mention-textarea.tsx"), "utf8");
|
||||
const canvasNodeSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node.tsx"), "utf8");
|
||||
const videoPlayerSource = readFileSync(resolve(import.meta.dir, "../src/components/video-player.tsx"), "utf8");
|
||||
|
||||
function node(id: string, type: CanvasNodeType): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} };
|
||||
}
|
||||
|
||||
describe("canvas media activation", () => {
|
||||
const nodes = [node("video", CanvasNodeType.Video), node("audio", CanvasNodeType.Audio), node("image", CanvasNodeType.Image)];
|
||||
const nodeById = new Map(nodes.map((item) => [item.id, item]));
|
||||
|
||||
test("activates exactly one selected video or audio node", () => {
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["video"]), nodeById)).toBe("video");
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["audio"]), nodeById)).toBe("audio");
|
||||
});
|
||||
|
||||
test("keeps media inactive for empty, multi-selection, and non-media selection", () => {
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["video", "audio"]), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["image"]), nodeById)).toBeNull();
|
||||
expect(resolveActiveCanvasMediaNodeId(new Set(["missing"]), nodeById)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas dimension header rendering", () => {
|
||||
test("tracks live viewport scale without React width commits", () => {
|
||||
expect(canvasNodeSource).toContain("calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))");
|
||||
expect(canvasNodeSource).toContain('"--canvas-node-width": `${node.width}px`');
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio canvas interaction", () => {
|
||||
test("uses a lightweight player without a native audio element or inactive mask", () => {
|
||||
expect(canvasNodeContentSource).toContain("<CanvasAudioPlayer node={node} theme={theme} />");
|
||||
expect(canvasNodeContentSource).not.toContain("<audio");
|
||||
expect(canvasNodeContentSource).not.toContain("InactiveAudioPreview");
|
||||
expect(canvasNodeContentSource.match(/function AudioNodeContent[\s\S]*?\n}\n/)?.[0] || "").not.toContain("useNodeResourceUrl");
|
||||
expect(canvasAudioPlayerSource).not.toContain("播放结束,点击重新播放");
|
||||
expect(canvasAudioPlayerSource).not.toContain("pr-24");
|
||||
expect(canvasAudioPlayerSource).toContain("formatAudioTime(snapshot.currentTimeMs)}/{durationMs ? formatAudioTime(durationMs) : \"--:--\"");
|
||||
});
|
||||
|
||||
test("isolates player controls from canvas gestures", () => {
|
||||
expect(canvasAudioPlayerSource).toContain("onPointerDown={stopCanvasEvent}");
|
||||
expect(canvasAudioPlayerSource).toContain("onMouseDown={stopCanvasEvent}");
|
||||
expect(canvasAudioPlayerSource).toContain("onWheel={stopCanvasEvent}");
|
||||
expect(canvasAudioPlayerSource).toContain("onClick={(event) => {");
|
||||
expect(canvasAudioPlayerSource).toContain("togglePlayback();");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("video canvas controls", () => {
|
||||
test("keeps the compact video's volume control visible", async () => {
|
||||
const playerCSS = await Bun.file(new URL("../src/components/video-player.css", import.meta.url)).text();
|
||||
expect(playerCSS).toContain('data-player-variant="compact"');
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(.vds-time-slider)");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(.vds-time-group)");
|
||||
expect(playerCSS).toContain("bottom: calc(100% + var(--gap, 10px))");
|
||||
expect(playerCSS).toContain(".vds-video-layout[data-sm] .vds-volume .vds-volume-popup");
|
||||
expect(playerCSS).toContain("left: 50%;");
|
||||
expect(playerCSS).toContain("place-items: center;");
|
||||
expect(playerCSS).toContain(".vds-video-layout[data-sm] .vds-volume .vds-volume-slider");
|
||||
expect(playerCSS).toContain(".vds-volume .vds-volume-popup::after");
|
||||
expect(playerCSS).toContain("bottom: calc(-1 * var(--gap, 6px))");
|
||||
expect(playerCSS).toContain(".canvas-video-player:is([data-fullscreen], :fullscreen, :-webkit-full-screen) .vds-video-layout .vds-controls-group:has(.vds-time-group)");
|
||||
expect(playerCSS).toContain("min-height: 82px;");
|
||||
expect(playerCSS).toContain("margin: 0;");
|
||||
expect(playerCSS).toContain("right: calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 22px)");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(.vds-time-group) .vds-fullscreen-button");
|
||||
expect(playerCSS).toContain("margin-left: auto;");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(.vds-time-slider) .vds-time-slider");
|
||||
expect(playerCSS).toContain("pointer-events: auto !important;");
|
||||
expect(playerCSS).toContain("touch-action: none;");
|
||||
expect(playerCSS).toContain("z-index: 11;");
|
||||
expect(playerCSS).toContain("left: calc(var(--canvas-video-control-size) * 3 + var(--canvas-video-control-gap) + 40px)");
|
||||
expect(playerCSS).toContain("padding: 0 0 0 calc(var(--canvas-video-control-size) + var(--canvas-video-control-gap) + 2px)");
|
||||
expect(playerCSS).toContain("pointer-events: none !important;");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(> .vds-play-button) > .vds-controls-spacer");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(> .vds-play-button) .vds-play-button");
|
||||
expect(playerCSS).toContain("transform: none;");
|
||||
expect(playerCSS).toContain(".vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-group)");
|
||||
expect(playerCSS).toContain(".vds-video-layout[data-lg] .vds-controls-group:has(.vds-time-slider)");
|
||||
expect(playerCSS).toContain("bottom: calc(var(--canvas-video-control-size) + 12px)");
|
||||
expect(playerCSS).toContain("flex: 0 0 var(--canvas-video-control-size)");
|
||||
expect(playerCSS).toContain("flex-shrink: 0");
|
||||
expect(playerCSS).toContain("--video-volume-height: 64px");
|
||||
expect(playerCSS).toContain("--video-volume-bg: rgba(12, 13, 16, 0.92)");
|
||||
expect(playerCSS).toContain("--video-volume-panel-width: 42px");
|
||||
expect(playerCSS).toContain("--video-volume-slider-width: 28px");
|
||||
expect(playerCSS).toContain("width: var(--video-volume-panel-width)");
|
||||
expect(playerCSS).toContain("--media-slider-width: var(--video-volume-slider-width)");
|
||||
expect(playerCSS).toContain('.canvas-video-player[data-no-audio="true"] .vds-volume .vds-mute-button');
|
||||
expect(playerCSS).toContain("cursor: not-allowed;");
|
||||
expect(playerCSS).toContain("data-player-variant=\"compact\"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen)");
|
||||
expect(playerCSS).toContain("height: var(--canvas-video-control-size);");
|
||||
expect(playerCSS).toContain("min-height: var(--canvas-video-control-size);");
|
||||
expect(playerCSS).toContain("padding: 0 0 0 calc(var(--canvas-video-control-size)");
|
||||
expect(playerCSS).toContain(".vds-controls-group:has(.vds-volume) > :not(.vds-volume)");
|
||||
expect(playerCSS).toContain("bottom: max(12px, env(safe-area-inset-bottom))");
|
||||
expect(playerCSS).toContain("play, time, progress, volume, fullscreen");
|
||||
expect(playerCSS).toContain("pointer-events: auto;");
|
||||
expect(playerCSS).toContain(".vds-controls-group > :is(.vds-caption-button, .vds-download-button, .vds-pip-button, .vds-airplay-button, .vds-google-cast-button, .vds-chapters-button)");
|
||||
expect(playerCSS).not.toContain("canvas-video-player-compact:not([data-fullscreen]) .vds-video-layout[data-sm] .vds-controls-group:first-child");
|
||||
expect(playerCSS).not.toContain("canvas-video-player-compact:not([data-fullscreen]) .vds-video-layout[data-lg] .vds-controls-group:last-child");
|
||||
});
|
||||
|
||||
test("does not duplicate the node title or expose player settings", () => {
|
||||
expect(videoPlayerSource).toContain("chapterTitle: null");
|
||||
expect(videoPlayerSource).toContain("settingsMenu: null");
|
||||
expect(videoPlayerSource).toContain('data-player-variant={compactControls ? "compact" : "dialog"}');
|
||||
});
|
||||
|
||||
test("uses the muted icon state only when a video is known to have no audio", () => {
|
||||
expect(videoPlayerSource).toContain("hasAudio?: boolean");
|
||||
expect(videoPlayerSource).toContain("mediaPlayerRef.current?.el");
|
||||
expect(videoPlayerSource).toContain("muted={noAudio ? true : undefined}");
|
||||
expect(videoPlayerSource).toContain('data-no-audio={noAudio ? "true" : undefined}');
|
||||
expect(videoPlayerSource).toContain("VolumeLow: defaultLayoutIcons.MuteButton.Mute");
|
||||
expect(videoPlayerSource).toContain("VolumeHigh: defaultLayoutIcons.MuteButton.Mute");
|
||||
expect(videoPlayerSource).toContain("muteButton.disabled = noAudio;");
|
||||
expect(videoPlayerSource).toContain("(volumeSlider as HTMLElement & { disabled?: boolean }).disabled = noAudio;");
|
||||
expect(videoPlayerSource).toContain('event.target.closest(".vds-volume,.vds-mute-button,.vds-volume-slider")');
|
||||
expect(videoPlayerSource).toContain("onKeyDown={stopCanvasControlInteraction}");
|
||||
expect(videoPlayerSource).not.toContain("onPointerDownCapture={stopCanvasControlInteraction}");
|
||||
expect(videoPlayerSource).not.toContain("onMouseDownCapture={stopCanvasControlInteraction}");
|
||||
expect(videoPlayerSource).not.toContain("onClickCapture={stopCanvasControlClick}");
|
||||
expect(canvasNodeContentSource).toContain("hasAudio={inferVideoHasAudio(node.metadata)} autoPlay={mediaActive}");
|
||||
expect(canvasNodeContentSource).toContain('if (["false", "0", "off", "no", "disabled"].includes(value || "")) return false;');
|
||||
});
|
||||
|
||||
test("recognizes an explicitly empty audio track list", () => {
|
||||
const media = { audioTracks: { length: 0 }, readyState: 4 } as unknown as HTMLMediaElement;
|
||||
expect(detectVideoAudioTrack(media)).toBeUndefined();
|
||||
expect(detectVideoAudioTrack({ audioTracks: { length: 0 }, readyState: 2 } as unknown as HTMLMediaElement)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not treat an undecoded audio byte counter as proof of silence", () => {
|
||||
const media = { readyState: 4, webkitAudioDecodedByteCount: 0 } as unknown as HTMLMediaElement;
|
||||
expect(detectVideoAudioTrack(media)).toBeUndefined();
|
||||
expect(detectVideoAudioTrack({ readyState: 4, webkitAudioDecodedByteCount: 128 } as unknown as HTMLMediaElement)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat an unsupported empty capture stream as proof of silence", () => {
|
||||
const media = { readyState: 4, captureStream: () => ({ getAudioTracks: () => [] }) } as unknown as HTMLMediaElement;
|
||||
expect(detectVideoAudioTrack(media)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("falls back to the MP4 track handler when browser audio APIs are unavailable", async () => {
|
||||
expect(await detectVideoAudioTrackFromBlob(new Blob([isoBmffMovie(["vide"])], { type: "video/mp4" }))).toBe(false);
|
||||
expect(await detectVideoAudioTrackFromBlob(new Blob([isoBmffMovie(["vide", "soun"])], { type: "video/mp4" }))).toBe(true);
|
||||
expect(await detectVideoAudioTrackFromBlob(new Blob([isoBmffMovie(["soun"])], { type: "video/mp4" }))).toBe(true);
|
||||
});
|
||||
|
||||
test("probes remote MP4 ranges when browser audio APIs are unavailable", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const movie = isoBmffMovie(["vide"]);
|
||||
globalThis.fetch = (async () => new Response(movie, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"content-range": `bytes 0-${movie.byteLength - 1}/${movie.byteLength}`,
|
||||
"content-length": String(movie.byteLength),
|
||||
},
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
expect(await detectVideoAudioTrackFromUrl(`https://media.example/silent-${movie.byteLength}.mp4`)).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the first activation click from being treated as a node drag", () => {
|
||||
expect(videoPlayerSource).toContain("onClick={stopCanvasControlClick}");
|
||||
expect(videoPlayerSource).toContain('event.target.closest(".vds-controls,.vds-menu-items")');
|
||||
expect(videoPlayerSource).toContain("event.nativeEvent?.composedPath?.()");
|
||||
expect(videoPlayerSource).toContain("if (autoPlay && !autoPlayAttemptedRef.current)");
|
||||
expect(videoPlayerSource).toContain("autoPlayAttemptedRef.current = true;");
|
||||
expect(videoPlayerSource).toContain("noGestures={dataCanvasNoZoom}");
|
||||
expect(videoPlayerSource).toContain("smallLayoutWhen={compactControls ? true : undefined}");
|
||||
});
|
||||
|
||||
test("allows a runtime-positive probe to correct stale silent metadata", () => {
|
||||
expect(videoPlayerSource).toContain("hasAudio === true || detectedHasAudio === true ? true");
|
||||
expect(videoPlayerSource).toContain("hasAudio === false ? false : undefined");
|
||||
expect(videoPlayerSource).toContain("detectedHasAudio === false ? false");
|
||||
expect(videoPlayerSource).toContain("detectVideoAudioTrackFromUrl(src)");
|
||||
});
|
||||
|
||||
test("raises only selected video nodes above the canvas dock so controls remain reachable", () => {
|
||||
expect(canvasNodeSource).toContain('isSelected && data.type === CanvasNodeType.Video ? "z-[var(--z-node-toolbar)]"');
|
||||
});
|
||||
});
|
||||
|
||||
function isoBmffMovie(handlerTypes: string[]) {
|
||||
const handlers = handlerTypes.map((handlerType) => {
|
||||
const payload = new Uint8Array(20);
|
||||
writeFourcc(payload, 8, handlerType);
|
||||
return box("trak", box("mdia", box("hdlr", payload)));
|
||||
});
|
||||
return concat([box("ftyp", new Uint8Array(4)), box("moov", handlers)]);
|
||||
}
|
||||
|
||||
function box(type: string, payload: Uint8Array | Uint8Array[]) {
|
||||
const content = Array.isArray(payload) ? concat(payload) : payload;
|
||||
const bytes = new Uint8Array(8 + content.byteLength);
|
||||
new DataView(bytes.buffer).setUint32(0, bytes.byteLength);
|
||||
writeFourcc(bytes, 4, type);
|
||||
bytes.set(content, 8);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concat(parts: Uint8Array[]) {
|
||||
const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeFourcc(bytes: Uint8Array, offset: number, value: string) {
|
||||
for (let index = 0; index < 4; index += 1) bytes[offset + index] = value.charCodeAt(index) || 0;
|
||||
}
|
||||
|
||||
describe("passive video previews", () => {
|
||||
test("素材引用菜单使用被动视频首帧回退,而不是视频图标", () => {
|
||||
expect(canvasMentionSource).toContain('muted playsInline preload="metadata"');
|
||||
expect(canvasMentionSource).toContain("onloadedmetadata = () => primeVideoPreviewFrame(media)");
|
||||
expect(canvasMentionSource).toContain("onLoadedMetadata={(event) => primeVideoPreviewFrame(event.currentTarget)}");
|
||||
expect(canvasMentionSource).toContain("video.currentTime = Math.min(0.001, video.duration)");
|
||||
expect(canvasMentionSource).toContain("reference.kind === \"video\" && reference.previewUrl");
|
||||
});
|
||||
|
||||
test("persists uploaded posters in video node metadata and prefers them over legacy previews", () => {
|
||||
const metadata = videoMetadata({
|
||||
url: "blob:video",
|
||||
storageKey: "video:user:1",
|
||||
bytes: 1024,
|
||||
mimeType: "video/mp4",
|
||||
hasAudio: false,
|
||||
preview: { url: "blob:poster", storageKey: "image:user:1", width: 400, height: 225, bytes: 256, mimeType: "image/jpeg" },
|
||||
});
|
||||
const video = node("video", CanvasNodeType.Video);
|
||||
video.metadata = { ...metadata, previewContent: "https://example.com/legacy-poster.jpg" };
|
||||
|
||||
expect(metadata.videoPreview).toEqual({ content: "blob:poster", storageKey: "image:user:1", width: 400, height: 225, bytes: 256, mimeType: "image/jpeg" });
|
||||
expect(metadata.hasAudio).toBe(false);
|
||||
expect(collectImageStorageKeys(metadata)).toContain("image:user:1");
|
||||
expect(canvasNodeVideoPreviewUrl(video)).toBe("blob:poster");
|
||||
});
|
||||
|
||||
test("uses explicit posters without ever returning the original video URL", () => {
|
||||
const video = node("video", CanvasNodeType.Video);
|
||||
video.metadata = { content: "https://example.com/video.mp4", previewContent: "https://example.com/poster.jpg" };
|
||||
expect(canvasNodeVideoPreviewUrl(video)).toBe("https://example.com/poster.jpg");
|
||||
|
||||
video.metadata.previewContent = video.metadata.content;
|
||||
expect(canvasNodeVideoPreviewUrl(video)).toBe("");
|
||||
});
|
||||
|
||||
test("derives LibTV snapshots and keeps unsupported videos as lightweight placeholders", () => {
|
||||
const video = node("video", CanvasNodeType.Video);
|
||||
video.metadata = { content: "https://libtv-res.liblib.art/path/video.mp4" };
|
||||
expect(canvasNodeVideoPreviewUrl(video)).toContain("video%2Fsnapshot");
|
||||
expect(canvasVideoAssetPreviewUrl("https://example.com/video.mp4")).toBe("");
|
||||
expect(canvasVideoAssetPreviewUrl("https://example.com/video.mp4", "https://example.com/video.mp4")).toBe("");
|
||||
expect(canvasVideoAssetPreviewUrl("https://example.com/video.mp4", "https://example.com/poster.jpg")).toBe("https://example.com/poster.jpg");
|
||||
});
|
||||
});
|
||||
21
web/test/canvas-node-drag-overlays.test.ts
Normal file
21
web/test/canvas-node-drag-overlays.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const projectSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
|
||||
const selectionControllerSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-selection-controller.ts"), "utf8");
|
||||
|
||||
describe("canvas node drag overlays", () => {
|
||||
test("hides floating editors and selection controls for the whole drag preview", () => {
|
||||
expect(projectSource).toContain("const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size);");
|
||||
expect(projectSource).toContain("dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving");
|
||||
expect(projectSource).toContain("angleNode?.metadata?.content && !isCanvasNodeMoving");
|
||||
expect(projectSource).toContain("emotionNode?.metadata?.content && !isCanvasNodeMoving");
|
||||
expect(projectSource).toContain("selectedNodeBounds && !selectionBox && !isCanvasNodeMoving");
|
||||
expect(projectSource).toContain("node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}");
|
||||
expect(projectSource).toContain("onNodeDragEnd: handleNodeDragEnd");
|
||||
expect(projectSource).toContain("setDialogNodeId(node.id);");
|
||||
expect(selectionControllerSource).toContain("if (clickedNodeId) onNodeDragEnd?.(clickedNodeId);");
|
||||
});
|
||||
});
|
||||
35
web/test/canvas-node-stack-order.test.ts
Normal file
35
web/test/canvas-node-stack-order.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { bringCanvasNodeToFront, sortCanvasNodesByStackOrder } from "../src/lib/canvas/canvas-node-stack-order";
|
||||
|
||||
const projectSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
|
||||
const worldLayersSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/canvas-project-world-layers.tsx"), "utf8");
|
||||
const selectionControllerSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-selection-controller.ts"), "utf8");
|
||||
|
||||
describe("canvas node stack order", () => {
|
||||
test("moves the interacted node to the paint-order end", () => {
|
||||
expect(bringCanvasNodeToFront(["a", "b"], "a")).toEqual(["b", "a"]);
|
||||
expect(bringCanvasNodeToFront(["a", "b"], "c")).toEqual(["a", "b", "c"]);
|
||||
const alreadyFront = ["a", "b"];
|
||||
expect(bringCanvasNodeToFront(alreadyFront, "b")).toBe(alreadyFront);
|
||||
});
|
||||
|
||||
test("keeps untouched nodes stable while the latest node remains on top after deselection", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
expect(sortCanvasNodesByStackOrder(nodes, ["b", "a"])).toEqual([{ id: "c" }, { id: "b" }, { id: "a" }]);
|
||||
});
|
||||
|
||||
test("keeps stack state separate from transient canvas selection", () => {
|
||||
expect(projectSource).toContain("const [nodeStackOrder, setNodeStackOrder]");
|
||||
expect(projectSource).toContain("bringNodeToFront(node.id)");
|
||||
expect(projectSource).toContain("onNodeBringToFront: handleNodeBringToFront");
|
||||
expect(projectSource).toContain("const handleNodeBringToFront = useCallback");
|
||||
expect(selectionControllerSource).toContain("onNodeBringToFront?.(nodeId)");
|
||||
expect(selectionControllerSource).toContain("so dragging also survives deselection");
|
||||
expect(projectSource).toContain("onDeselect: handleCanvasDeselect");
|
||||
expect(worldLayersSource).toContain("sortCanvasNodesByStackOrder");
|
||||
expect(worldLayersSource).toContain("props.nodeStackOrder");
|
||||
});
|
||||
});
|
||||
14
web/test/canvas-node-title-interaction.test.ts
Normal file
14
web/test/canvas-node-title-interaction.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const canvasStylesSource = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
describe("canvas node title interaction", () => {
|
||||
test("keeps the toolbar hover bridge from intercepting the external title", () => {
|
||||
const hoverBridge = canvasStylesSource.match(/\.canvas-node-toolbar::after\s*\{([\s\S]*?)\}/)?.[1] || "";
|
||||
|
||||
expect(hoverBridge).toContain("pointer-events: none;");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildNodeMentionReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { buildAssetMentionReferences, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset";
|
||||
import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function videoNode(id: string): CanvasNodeData {
|
||||
@@ -76,6 +78,89 @@ describe("collectUpstreamVideoNodes", () => {
|
||||
});
|
||||
|
||||
describe("canvas resource mention slots", () => {
|
||||
test("素材库视频优先使用封面,没有封面时保留首帧视频回退源", () => {
|
||||
const poster = buildAssetMentionReferences([{
|
||||
id: "video-with-poster",
|
||||
kind: "video",
|
||||
title: "带封面视频",
|
||||
coverUrl: "https://cdn.example.com/poster.jpg",
|
||||
tags: [],
|
||||
createdAt: "2026-08-31T00:00:00.000Z",
|
||||
updatedAt: "2026-08-31T00:00:00.000Z",
|
||||
data: { url: "https://cdn.example.com/video.mp4", storageKey: "resource:video", width: 1280, height: 720, bytes: 1, mimeType: "video/mp4" },
|
||||
}])[0];
|
||||
expect(poster?.previewUrl).toBe("https://cdn.example.com/poster.jpg");
|
||||
expect(poster?.mediaUrl).toBeUndefined();
|
||||
|
||||
const legacy = buildAssetMentionReferences([{
|
||||
id: "legacy-video",
|
||||
kind: "video",
|
||||
title: "旧视频",
|
||||
coverUrl: "https://cdn.example.com/video.mp4",
|
||||
tags: [],
|
||||
createdAt: "2026-08-31T00:00:00.000Z",
|
||||
updatedAt: "2026-08-31T00:00:00.000Z",
|
||||
data: { url: "https://cdn.example.com/video.mp4", storageKey: "resource:legacy-video", width: 1280, height: 720, bytes: 1, mimeType: "video/mp4" },
|
||||
}])[0];
|
||||
expect(legacy?.previewUrl).toBe("");
|
||||
expect(legacy?.mediaUrl).toBe("https://cdn.example.com/video.mp4");
|
||||
});
|
||||
|
||||
test("保存画布视频资产时保留节点已有的静态首帧", () => {
|
||||
const node = videoNode("video-with-poster");
|
||||
node.metadata = {
|
||||
content: "https://cdn.example.com/video.mp4",
|
||||
videoPreview: { content: "https://cdn.example.com/poster.jpg", storageKey: "resource:poster" },
|
||||
};
|
||||
const asset = canvasNodeToAsset(node, { canvasId: "canvas", source: "canvas-upload" });
|
||||
expect(asset?.kind).toBe("video");
|
||||
expect(asset && "coverUrl" in asset ? asset.coverUrl : "").toBe("https://cdn.example.com/poster.jpg");
|
||||
});
|
||||
|
||||
test("上传视频作为生成设置引用时携带首帧预览,而不是播放器地址", () => {
|
||||
const source = videoNode("uploaded-video");
|
||||
source.metadata = {
|
||||
content: "https://cdn.example.com/video.mp4",
|
||||
storageKey: "video:user:uploaded",
|
||||
videoPreview: { content: "blob:poster", storageKey: "image:user:poster", width: 400, height: 225 },
|
||||
};
|
||||
const config: CanvasNodeData = {
|
||||
id: "config",
|
||||
type: CanvasNodeType.Config,
|
||||
title: "生成设置",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: {},
|
||||
};
|
||||
const inputs = buildNodeGenerationInputs(config.id, [source, config], [connection(source.id, config.id)]);
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0]?.type).toBe("video");
|
||||
expect(inputs[0]?.previewUrl).toBe("blob:poster");
|
||||
expect(inputs[0]?.previewUrl).not.toBe(source.metadata.content);
|
||||
});
|
||||
|
||||
test("视频引用只暴露静态首帧,不把原视频当缩略图", () => {
|
||||
const genericVideo = videoNode("generic-video");
|
||||
genericVideo.metadata = { content: "https://example.com/video.mp4" };
|
||||
const libtvVideo = videoNode("libtv-video");
|
||||
libtvVideo.metadata = { content: "https://libtv-res.liblib.art/path/video.mp4" };
|
||||
|
||||
const references = buildOrderedCanvasResourceReferences([genericVideo, libtvVideo]);
|
||||
expect(references[0]?.previewUrl).toBe("");
|
||||
expect(references[1]?.previewUrl).toContain("video%2Fsnapshot");
|
||||
|
||||
const uploaded = videoNode("uploaded-video");
|
||||
uploaded.metadata = {
|
||||
content: "https://cdn.example.com/video.mp4",
|
||||
storageKey: "video:user:source",
|
||||
videoPreview: { content: "https://cdn.example.com/poster.jpg", storageKey: "image:user:poster" },
|
||||
};
|
||||
const [uploadedReference] = buildOrderedCanvasResourceReferences([uploaded]);
|
||||
expect(uploadedReference?.previewStorageKey).toBe("image:user:poster");
|
||||
expect(uploadedReference?.storageKey).toBe("video:user:source");
|
||||
});
|
||||
|
||||
test("画布节点引用只保存类型位置,不保存节点 ID", () => {
|
||||
const target = videoNode("target");
|
||||
const image = imageNode("image-a");
|
||||
|
||||
@@ -192,7 +192,7 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
|
||||
|
||||
test("Canvas fullscreen video restores large controls instead of keeping the compact node layout", async () => {
|
||||
const playerCSS = await Bun.file(new URL("../src/components/video-player.css", import.meta.url)).text();
|
||||
expect(playerCSS).toContain(".canvas-video-player-compact:not([data-fullscreen])");
|
||||
expect(playerCSS).toContain('.canvas-video-player[data-player-variant="compact"]:not([data-fullscreen])');
|
||||
expect(playerCSS).toContain("--media-fullscreen-button-size: 60px");
|
||||
expect(playerCSS).toContain("--media-slider-track-height: 8px");
|
||||
expect(playerCSS).toContain("min-height: 64px");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "../src/lib/canvas/libtv-import";
|
||||
import { buildLibTVImagePreviewUrl, buildLibTVVideoPreviewUrl, buildLibTVVideoSourceUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "../src/lib/canvas/libtv-import";
|
||||
|
||||
describe("LibTV canvas import helpers", () => {
|
||||
test("extracts projectId from LibTV share links", () => {
|
||||
@@ -23,4 +23,17 @@ describe("LibTV canvas import helpers", () => {
|
||||
expect(buildLibTVImagePreviewUrl(original)).toBe("https://libtv-res.liblib.art/path/example.png?x-oss-process=image%2Fresize%2Cw_960");
|
||||
expect(buildLibTVImagePreviewUrl("https://example.com/path/example.png")).toBe("https://example.com/path/example.png");
|
||||
});
|
||||
|
||||
test("builds a 400px OSS snapshot for LibTV videos only", () => {
|
||||
const original = "https://libtv-res.liblib.art/path/example.mp4";
|
||||
expect(buildLibTVVideoPreviewUrl(original)).toBe("https://libtv-res.liblib.art/path/example.mp4?x-oss-process=video%2Fsnapshot%2Ct_0%2Cf_jpg%2Cw_400%2Cm_fast%2Car_auto");
|
||||
expect(buildLibTVVideoPreviewUrl("https://example.com/path/example.mp4")).toBe("");
|
||||
expect(buildLibTVVideoPreviewUrl("not-a-url")).toBe("");
|
||||
});
|
||||
|
||||
test("removes a persisted OSS snapshot transform before video playback", () => {
|
||||
const snapshot = "https://libtv-res.liblib.art/path/example.mp4?x-oss-process=video%2Fsnapshot%2Ct_0%2Cf_jpg%2Cw_400";
|
||||
expect(buildLibTVVideoSourceUrl(snapshot)).toBe("https://libtv-res.liblib.art/path/example.mp4");
|
||||
expect(buildLibTVVideoSourceUrl("https://example.com/path/example.mp4?x-oss-process=video%2Fsnapshot")).toBe("https://example.com/path/example.mp4?x-oss-process=video%2Fsnapshot");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user