mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): correct WAAPI rediscovery seek baselines and preview reuse invalidation
This commit is contained in:
@@ -398,7 +398,8 @@ async function runEmbeddedMode(
|
||||
userDataDir?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js");
|
||||
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
|
||||
await import("../server/studioServer.js");
|
||||
|
||||
const pName = options?.projectName ?? basename(dir);
|
||||
const studioBundle = resolveStudioBundle();
|
||||
@@ -422,10 +423,17 @@ async function runEmbeddedMode(
|
||||
}
|
||||
|
||||
const { app } = createStudioServer({ projectDir: dir, projectName: pName });
|
||||
const serverBuildSignature = await loadPreviewServerBuildSignature();
|
||||
|
||||
let result: FindPortResult;
|
||||
try {
|
||||
result = await findPortAndServe(app.fetch, startPort, dir, !!options?.forceNew);
|
||||
result = await findPortAndServe(
|
||||
app.fetch,
|
||||
startPort,
|
||||
dir,
|
||||
!!options?.forceNew,
|
||||
serverBuildSignature,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
s.stop(c.error("Failed to start studio"));
|
||||
console.error();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { PORT_PROBE_HOSTS, testPortOnAllHosts } from "./portUtils.js";
|
||||
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
|
||||
import { PORT_PROBE_HOSTS, detectHyperframesServer, testPortOnAllHosts } from "./portUtils.js";
|
||||
|
||||
const openServers: Server[] = [];
|
||||
const openHttpServers: HttpServer[] = [];
|
||||
|
||||
async function allocFreePort(): Promise<number> {
|
||||
const srv = createServer();
|
||||
@@ -24,9 +26,30 @@ afterEach(async () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
openHttpServers.splice(0).map(
|
||||
(s) =>
|
||||
new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function startConfigProbeServer(payload: Record<string, unknown>): Promise<number> {
|
||||
const server = createHttpServer((_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
openHttpServers.push(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
return (server.address() as import("node:net").AddressInfo).port;
|
||||
}
|
||||
|
||||
describe("testPortOnAllHosts — real-socket behaviour (OS-dependent)", () => {
|
||||
// These exercise the real network stack. On Linux the buggy parallel
|
||||
// implementation reliably fails the first test (issue #309 repro); on
|
||||
@@ -97,3 +120,35 @@ describe("testPortOnAllHosts — sequential contract (platform-agnostic)", () =>
|
||||
expect(hostsProbed).toEqual(["127.0.0.1", "0.0.0.0"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectHyperframesServer", () => {
|
||||
it("treats same-project servers with a different server build signature as mismatch", async () => {
|
||||
const projectDir = "/tmp/demo-project";
|
||||
const port = await startConfigProbeServer({
|
||||
isHyperframes: true,
|
||||
projectName: "demo-project",
|
||||
projectDir,
|
||||
serverBuildSignature: "old-build",
|
||||
version: "0.6.42",
|
||||
});
|
||||
|
||||
const result = await detectHyperframesServer(port, projectDir, "new-build");
|
||||
|
||||
expect(result).toEqual({ type: "mismatch", projectName: "demo-project" });
|
||||
});
|
||||
|
||||
it("treats same-project servers with the same server build signature as match", async () => {
|
||||
const projectDir = "/tmp/demo-project";
|
||||
const port = await startConfigProbeServer({
|
||||
isHyperframes: true,
|
||||
projectName: "demo-project",
|
||||
projectDir,
|
||||
serverBuildSignature: "same-build",
|
||||
version: "0.6.42",
|
||||
});
|
||||
|
||||
const result = await detectHyperframesServer(port, projectDir, "same-build");
|
||||
|
||||
expect(result).toEqual({ type: "match" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,7 @@ interface HyperframesConfigResponse {
|
||||
isHyperframes: boolean;
|
||||
projectName: string;
|
||||
projectDir: string;
|
||||
serverBuildSignature?: string | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@@ -114,6 +115,7 @@ export type DetectionResult =
|
||||
export function detectHyperframesServer(
|
||||
port: number,
|
||||
normalizedProjectDir: string,
|
||||
expectedServerBuildSignature: string | null = null,
|
||||
): Promise<DetectionResult> {
|
||||
return new Promise<DetectionResult>((resolveResult) => {
|
||||
const req = http.get(
|
||||
@@ -152,6 +154,12 @@ export function detectHyperframesServer(
|
||||
const normalize = (p: string) => resolve(p).replace(/\\/g, "/").toLowerCase();
|
||||
|
||||
if (normalize(json.projectDir) === normalizedProjectDir) {
|
||||
if (
|
||||
expectedServerBuildSignature !== null &&
|
||||
json.serverBuildSignature !== expectedServerBuildSignature
|
||||
) {
|
||||
return resolveResult({ type: "mismatch", projectName: json.projectName });
|
||||
}
|
||||
return resolveResult({ type: "match" });
|
||||
}
|
||||
|
||||
@@ -327,6 +335,7 @@ export async function findPortAndServe(
|
||||
startPort: number,
|
||||
projectDir: string,
|
||||
forceNew: boolean,
|
||||
expectedServerBuildSignature: string | null = null,
|
||||
): Promise<FindPortResult> {
|
||||
const { createAdaptorServer } = await import("@hono/node-server");
|
||||
const normalizedDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase();
|
||||
@@ -366,7 +375,11 @@ export async function findPortAndServe(
|
||||
|
||||
// Port is occupied — probe for existing HyperFrames instance
|
||||
if (!forceNew) {
|
||||
const detection = await detectHyperframesServer(port, normalizedDir);
|
||||
const detection = await detectHyperframesServer(
|
||||
port,
|
||||
normalizedDir,
|
||||
expectedServerBuildSignature,
|
||||
);
|
||||
if (detection.type === "match") {
|
||||
return { type: "already-running", port };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
@@ -16,6 +17,21 @@ export async function loadRuntimeSource(): Promise<string | null> {
|
||||
return (await buildFromSource()) ?? (await getInlinedRuntime()) ?? readPrebuiltArtifact();
|
||||
}
|
||||
|
||||
export async function loadRuntimeSourceSignature(): Promise<string | null> {
|
||||
const source = await loadRuntimeSource();
|
||||
if (!source) return null;
|
||||
return createHash("sha256").update(source).digest("hex");
|
||||
}
|
||||
|
||||
export function hashSignatureParts(parts: Array<string | null | undefined>): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const part of parts) {
|
||||
hash.update(part ?? "");
|
||||
hash.update("\n--hf-signature-part--\n");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
// ── Strategy 1: live build from source (dev only) ──────────────────────────
|
||||
|
||||
const ENTRY_TS = resolve(__dirname, "..", "..", "..", "core", "src", "runtime", "entry.ts");
|
||||
|
||||
@@ -10,7 +10,11 @@ import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
import {
|
||||
hashSignatureParts,
|
||||
loadRuntimeSource,
|
||||
loadRuntimeSourceSignature,
|
||||
} from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||
import {
|
||||
@@ -184,6 +188,25 @@ export interface StudioServer {
|
||||
watcher: ProjectWatcher;
|
||||
}
|
||||
|
||||
export async function loadPreviewServerBuildSignature(): Promise<string> {
|
||||
const runtimeSignature = await loadRuntimeSourceSignature();
|
||||
const studioBundle = resolveStudioBundle();
|
||||
const studioIndex =
|
||||
studioBundle.available && existsSync(studioBundle.indexPath)
|
||||
? readFileSync(studioBundle.indexPath, "utf-8")
|
||||
: "";
|
||||
return hashSignatureParts([
|
||||
version,
|
||||
runtimeSignature,
|
||||
studioIndex,
|
||||
createStudioServer.toString(),
|
||||
createStudioApi.toString(),
|
||||
createProjectSignature.toString(),
|
||||
getMimeType.toString(),
|
||||
getElementScreenshotClip.toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
const { projectDir, projectName } = options;
|
||||
const projectId = projectName || basename(projectDir);
|
||||
@@ -445,12 +468,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// HyperFrames instances and reuse them instead of spawning duplicates.
|
||||
// See portUtils.ts detectHyperframesServer() for the consumer.
|
||||
app.get("/__hyperframes_config", (c) => {
|
||||
return c.json({
|
||||
isHyperframes: true,
|
||||
projectName: projectId,
|
||||
projectDir: projectDir,
|
||||
version,
|
||||
});
|
||||
const serve = async () => {
|
||||
const serverBuildSignature = await loadPreviewServerBuildSignature();
|
||||
return c.json({
|
||||
isHyperframes: true,
|
||||
projectName: projectId,
|
||||
projectDir: projectDir,
|
||||
serverBuildSignature,
|
||||
version,
|
||||
});
|
||||
};
|
||||
return serve();
|
||||
});
|
||||
|
||||
// CLI-specific routes (before shared API)
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { createWaapiAdapter } from "./waapi";
|
||||
|
||||
describe("waapi adapter", () => {
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
getAnimations: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDocument === undefined) {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
return;
|
||||
}
|
||||
|
||||
(globalThis as { document?: unknown }).document = originalDocument;
|
||||
});
|
||||
|
||||
it("has correct name", () => {
|
||||
expect(createWaapiAdapter().name).toBe("waapi");
|
||||
});
|
||||
@@ -87,4 +104,76 @@ describe("waapi adapter", () => {
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("anchors newly discovered WAAPI animations to the seek where they first appear", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 0 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
adapter.seek({ time: 0.7 });
|
||||
expect(existing.currentTime).toBe(700);
|
||||
expect(dynamic.currentTime).toBe(0);
|
||||
|
||||
adapter.seek({ time: 0.8 });
|
||||
expect(dynamic.currentTime).toBe(100);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("rebases newly discovered WAAPI animations that inherit absolute composition time", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 700 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
adapter.seek({ time: 0.7 });
|
||||
expect(dynamic.currentTime).toBe(0);
|
||||
|
||||
adapter.seek({ time: 0.8 });
|
||||
expect(dynamic.currentTime).toBe(100);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("does not double-count inherited absolute time when discover runs again after time has advanced", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 700 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 0.7 });
|
||||
|
||||
expect(dynamic.currentTime).toBe(200);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,77 @@ import type { RuntimeDeterministicAdapter } from "../types";
|
||||
import { swallow } from "../diagnostics";
|
||||
|
||||
export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
let didDiscover = false;
|
||||
let lastSeekTimeMs = 0;
|
||||
const baselines = new WeakMap<
|
||||
Animation,
|
||||
{
|
||||
compositionTimeMs: number;
|
||||
animationTimeMs: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const snapshotAnimations = () => {
|
||||
if (!document.getAnimations) return [];
|
||||
try {
|
||||
return document.getAnimations();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readAnimationTimeMs = (animation: Animation) => {
|
||||
const raw = Number(animation.currentTime);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||||
};
|
||||
|
||||
const normalizeInitialAnimationTimeMs = (animationTimeMs: number, compositionTimeMs: number) => {
|
||||
if (compositionTimeMs <= 0) {
|
||||
return animationTimeMs;
|
||||
}
|
||||
|
||||
if (animationTimeMs >= compositionTimeMs) {
|
||||
return Math.max(0, animationTimeMs - compositionTimeMs);
|
||||
}
|
||||
|
||||
return animationTimeMs;
|
||||
};
|
||||
|
||||
const ensureBaseline = (animation: Animation, compositionTimeMs: number) => {
|
||||
const existing = baselines.get(animation);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const baseline = {
|
||||
compositionTimeMs,
|
||||
animationTimeMs: didDiscover
|
||||
? normalizeInitialAnimationTimeMs(readAnimationTimeMs(animation), compositionTimeMs)
|
||||
: readAnimationTimeMs(animation),
|
||||
};
|
||||
baselines.set(animation, baseline);
|
||||
return baseline;
|
||||
};
|
||||
|
||||
return {
|
||||
name: "waapi",
|
||||
discover: () => {},
|
||||
discover: () => {
|
||||
didDiscover = true;
|
||||
for (const animation of snapshotAnimations()) {
|
||||
ensureBaseline(animation, lastSeekTimeMs);
|
||||
}
|
||||
},
|
||||
seek: (ctx) => {
|
||||
if (!document.getAnimations) return;
|
||||
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
|
||||
for (const animation of document.getAnimations()) {
|
||||
lastSeekTimeMs = timeMs;
|
||||
for (const animation of snapshotAnimations()) {
|
||||
const baseline = didDiscover
|
||||
? ensureBaseline(animation, timeMs)
|
||||
: ensureBaseline(animation, 0);
|
||||
const localTimeMs =
|
||||
baseline.animationTimeMs + Math.max(0, timeMs - baseline.compositionTimeMs);
|
||||
try {
|
||||
animation.currentTime = timeMs;
|
||||
animation.currentTime = localTimeMs;
|
||||
} catch (err) {
|
||||
// ignore animations that reject currentTime writes
|
||||
swallow("runtime.adapters.waapi.site1", err);
|
||||
|
||||
Reference in New Issue
Block a user