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:
wurendao007
2026-08-31 09:00:37 +08:00
committed by GitHub
parent 21facff923
commit 8ebceecbb6
58 changed files with 2662 additions and 207 deletions

View 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);
});
});

View File

@@ -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 });

View 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:");
});
});

View 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");
});
});

View 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);");
});
});

View 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");
});
});

View 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;");
});
});

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");
});
});