Skip to content

Commit d146dc9

Browse files
authored
Merge branch 'heygen-com:main' into main
2 parents 643ecce + ad5229a commit d146dc9

128 files changed

Lines changed: 4789 additions & 766 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ jobs:
218218
- run: bun run --cwd packages/core build:hyperframes-runtime
219219
- run: bun run --filter '!@hyperframes/producer' test
220220

221+
sdk-tests:
222+
name: "SDK: unit + contract + smoke"
223+
needs: changes
224+
if: needs.changes.outputs.code == 'true'
225+
runs-on: ubuntu-latest
226+
timeout-minutes: 5
227+
steps:
228+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
229+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
230+
- run: bun install --frozen-lockfile
231+
- run: bun run --filter @hyperframes/sdk test
232+
221233
test-runtime-contract:
222234
name: "Test: runtime contract"
223235
needs: changes

Dockerfile.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ COPY packages/studio/package.json packages/studio/package.json
8080
COPY packages/shader-transitions/package.json packages/shader-transitions/package.json
8181
COPY packages/aws-lambda/package.json packages/aws-lambda/package.json
8282
COPY packages/gcp-cloud-run/package.json packages/gcp-cloud-run/package.json
83+
COPY packages/sdk/package.json packages/sdk/package.json
8384
RUN bun install --frozen-lockfile
8485

8586
# Copy source

docs/changelog.mdx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,79 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
88

99
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
1010

11+
<Update
12+
label="HyperFrames v0.6.95"
13+
description="Released - 2026-06-12"
14+
tags={["Release", "Engine"]}
15+
>
16+
Engine: adaptive memory-aware encoding with system memory monitoring, chunked encoder improvements, and streaming encoder resilience.
17+
18+
## Features
19+
20+
- **Engine:** Adaptive memory-aware encoding with system memory monitoring and dynamic chunk sizing
21+
- **Engine:** Chunked encoder improvements for parallel coordination
22+
- **Engine:** Streaming encoder resilience with better error recovery
23+
24+
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.94...v0.6.95).
25+
</Update>
26+
27+
<Update
28+
label="HyperFrames v0.6.94"
29+
description="Released - 2026-06-12"
30+
tags={["Release", "Studio"]}
31+
>
32+
Remove off-screen element indicators for UX redesign. The NLELayout unclipped overlay split remains for selection handle interaction near composition edges.
33+
34+
## Fixes
35+
36+
- **Studio:** Remove off-screen element indicators (UX redesign pending) ([#1376](https://github.com/heygen-com/hyperframes/pull/1376))
37+
- **Studio:** Clean up unused `onSelectElementById` prop ([#1377](https://github.com/heygen-com/hyperframes/pull/1377))
38+
39+
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.93...v0.6.94).
40+
</Update>
41+
42+
<Update
43+
label="HyperFrames v0.6.93"
44+
description="Released - 2026-06-12"
45+
tags={["Release", "Studio"]}
46+
>
47+
Hotfix: disable keyframes feature flag by default. The flag was accidentally shipped as enabled in v0.6.92.
48+
49+
## Fixes
50+
51+
- **Studio:** Set `STUDIO_KEYFRAMES_ENABLED` default to `false`
52+
53+
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.92...v0.6.93).
54+
</Update>
55+
56+
<Update
57+
label="HyperFrames v0.6.92"
58+
description="Released - 2026-06-12"
59+
tags={["Release", "Studio", "Core"]}
60+
>
61+
Per-property-group keyframe architecture: the keyframe system redesign ships across 7 stacked PRs. Each GSAP tween now targets a single property group (position, scale, rotation, etc.), eliminating cross-property contamination during drag, resize, and gesture recording.
62+
63+
## Features
64+
65+
- **Core:** Per-property-group type system (`PropertyGroupName`, `PROPERTY_GROUPS`, `classifyPropertyGroup`) ([#1354](https://github.com/heygen-com/hyperframes/pull/1354))
66+
- **Core:** `split-into-property-groups` and `replace-with-keyframes` server mutations ([#1355](https://github.com/heygen-com/hyperframes/pull/1355))
67+
- **Core:** `delete-all-for-selector` atomic mutation for bulk animation removal
68+
- **Studio:** GSAP drag intercept enabled by default with per-property-group routing ([#1356](https://github.com/heygen-com/hyperframes/pull/1356))
69+
- **Studio:** Gesture recording merges into existing position tweens instead of replacing ([#1359](https://github.com/heygen-com/hyperframes/pull/1359))
70+
- **Studio:** Unclipped overlay for off-screen element interaction ([#1360](https://github.com/heygen-com/hyperframes/pull/1360))
71+
72+
## Fixes
73+
74+
- **Studio:** Keyframe cache tags with `propertyGroup` for group-aware operations ([#1357](https://github.com/heygen-com/hyperframes/pull/1357))
75+
- **Studio:** Property panel routes keyframe diamonds to correct animation per property ([#1358](https://github.com/heygen-com/hyperframes/pull/1358))
76+
- **Studio:** Context menus portaled to `document.body` with smart overflow positioning
77+
- **Studio:** Delete All Keyframes uses atomic server mutation (no stale ID race)
78+
- **Studio:** Block CSS drag on GSAP-animated elements to prevent keyframe corruption
79+
- **Studio:** `onMoveKeyframe` percentage-space conversion (clip-relative to tween-relative)
80+
81+
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.91...v0.6.92).
82+
</Update>
83+
1184
<Update
1285
label="HyperFrames v0.6.91"
1386
description="Released - 2026-06-11"

packages/aws-lambda/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/aws-lambda",
3-
"version": "0.6.91",
3+
"version": "0.6.95",
44
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
55
"repository": {
66
"type": "git",

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/cli",
3-
"version": "0.6.91",
3+
"version": "0.6.95",
44
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
55
"repository": {
66
"type": "git",

packages/cli/src/background-removal/pipeline.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file complexity
12
/**
23
* Background-removal rendering pipeline.
34
*
@@ -14,7 +15,7 @@
1415
*/
1516
import { spawn } from "node:child_process";
1617
import { extname } from "node:path";
17-
import { hasFFmpeg, hasFFprobe } from "../whisper/manager.js";
18+
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
1819
import { createSession, type Session } from "./inference.js";
1920
import { type Device, type ModelId } from "./manager.js";
2021

@@ -263,8 +264,9 @@ export function resolveRenderTargets(
263264
}
264265

265266
export async function render(options: RenderOptions): Promise<RenderResult> {
266-
if (!hasFFmpeg() || !hasFFprobe()) {
267-
throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg");
267+
const ffmpegPath = findFFmpeg();
268+
if (!ffmpegPath || !findFFprobe()) {
269+
throw new Error(`ffmpeg and ffprobe are required. Install: ${getFFmpegInstallHint()}`);
268270
}
269271

270272
const { format, bgFormat } = resolveRenderTargets(
@@ -291,7 +293,14 @@ export async function render(options: RenderOptions): Promise<RenderResult> {
291293

292294
try {
293295
const start = Date.now();
294-
const framesProcessed = await runPipeline(options, session, media, format, bgFormat);
296+
const framesProcessed = await runPipeline(
297+
options,
298+
session,
299+
media,
300+
format,
301+
bgFormat,
302+
ffmpegPath,
303+
);
295304
const durationSeconds = (Date.now() - start) / 1000;
296305
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
297306

@@ -321,8 +330,13 @@ interface FfmpegProc {
321330
type StdioFd = "ignore" | "pipe";
322331
type StdioTuple = [StdioFd, StdioFd, StdioFd];
323332

324-
function spawnFfmpeg(args: string[], label: string, stdio: StdioTuple): FfmpegProc {
325-
const proc = spawn("ffmpeg", args, { stdio });
333+
function spawnFfmpeg(
334+
ffmpegPath: string,
335+
args: string[],
336+
label: string,
337+
stdio: StdioTuple,
338+
): FfmpegProc {
339+
const proc = spawn(ffmpegPath, args, { stdio });
326340
let stderrBuf = "";
327341
proc.stderr?.on("data", (d: Buffer) => {
328342
stderrBuf += d.toString();
@@ -343,19 +357,22 @@ async function runPipeline(
343357
media: MediaInfo,
344358
format: OutputFormat,
345359
bgFormat: OutputFormat | undefined,
360+
ffmpegPath: string,
346361
): Promise<number> {
347362
const { inputPath, outputPath, backgroundOutputPath } = options;
348363
const { width, height, fps, frameCount } = media;
349364
const frameBytes = width * height * 3;
350365
const quality = options.quality ?? DEFAULT_QUALITY;
351366

352367
const decoder = spawnFfmpeg(
368+
ffmpegPath,
353369
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
354370
"ffmpeg decoder",
355371
["ignore", "pipe", "pipe"],
356372
);
357373

358374
const fg = spawnFfmpeg(
375+
ffmpegPath,
359376
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
360377
"ffmpeg encoder",
361378
["pipe", "ignore", "pipe"],
@@ -364,6 +381,7 @@ async function runPipeline(
364381
const bg =
365382
backgroundOutputPath && bgFormat
366383
? spawnFfmpeg(
384+
ffmpegPath,
367385
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
368386
"ffmpeg background encoder",
369387
["pipe", "ignore", "pipe"],

packages/cli/src/browser/ffmpeg.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
// fallow-ignore-file code-duplication
12
import { execSync } from "node:child_process";
3+
import { existsSync } from "node:fs";
4+
import { resolve } from "node:path";
25

3-
export function findFFmpeg(): string | undefined {
6+
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
7+
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
8+
9+
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
410
try {
5-
const cmd = process.platform === "win32" ? "where ffmpeg" : "which ffmpeg";
11+
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
612
const output = execSync(cmd, {
713
encoding: "utf-8",
814
stdio: ["pipe", "pipe", "pipe"],
@@ -12,18 +18,37 @@ export function findFFmpeg(): string | undefined {
1218
.split(/\r?\n/)
1319
.map((s) => s.trim())
1420
.find(Boolean);
15-
return first || undefined;
21+
return first ? resolve(first) : undefined;
1622
} catch {
1723
return undefined;
1824
}
1925
}
2026

27+
function findConfiguredBinary(
28+
envName: string,
29+
binaryName: "ffmpeg" | "ffprobe",
30+
): string | undefined {
31+
const configured = process.env[envName]?.trim();
32+
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
33+
return findOnPath(binaryName);
34+
}
35+
36+
export function findFFmpeg(): string | undefined {
37+
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
38+
}
39+
40+
export function findFFprobe(): string | undefined {
41+
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
42+
}
43+
2144
export function getFFmpegInstallHint(): string {
2245
switch (process.platform) {
2346
case "darwin":
2447
return "brew install ffmpeg";
2548
case "linux":
2649
return "sudo apt install ffmpeg";
50+
case "win32":
51+
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
2752
default:
2853
return "https://ffmpeg.org/download.html";
2954
}

packages/cli/src/browser/manager.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
/**
23
* Browser-binary resolution tests for `findBrowser()`.
34
*
@@ -72,30 +73,34 @@ function installFsMocks({ existing, dirs }: FsMockOptions) {
7273
function installPuppeteerBrowsersMock(
7374
opts: {
7475
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
76+
installResult?: { executablePath: string };
7577
} = {},
7678
) {
7779
vi.doMock("@puppeteer/browsers", () => ({
7880
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
7981
detectBrowserPlatform: () => "linux",
8082
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
81-
install: vi.fn(),
83+
install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }),
8284
}));
8385
}
8486

8587
describe("findBrowser — cache resolution", () => {
8688
const origPlatform = process.platform;
89+
const origArch = process.arch;
8790

8891
beforeEach(() => {
8992
vi.resetModules();
9093
// Force Linux for the system-fallback warning assertions. The
9194
// `Object.defineProperty` dance is needed because `process.platform` is a
9295
// getter on Node — direct assignment is silently a no-op.
9396
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
97+
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
9498
delete process.env["HYPERFRAMES_BROWSER_PATH"];
9599
});
96100

97101
afterEach(() => {
98102
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
103+
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
99104
vi.restoreAllMocks();
100105
vi.doUnmock("node:fs");
101106
vi.doUnmock("node:os");
@@ -117,6 +122,28 @@ describe("findBrowser — cache resolution", () => {
117122
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
118123
});
119124

125+
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
126+
const redownloadedBinary = join(
127+
HF_CACHE,
128+
"chrome-headless-shell",
129+
"linux-131.0.6778.85",
130+
"chrome-headless-shell-linux64",
131+
"redownloaded-chrome-headless-shell",
132+
);
133+
installFsMocks({ existing: new Set([HF_CACHE]) });
134+
installPuppeteerBrowsersMock({
135+
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
136+
installResult: { executablePath: redownloadedBinary },
137+
});
138+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
139+
140+
const { findBrowser } = await import("./manager.js");
141+
const result = await findBrowser();
142+
143+
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
144+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
145+
});
146+
120147
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
121148
// Empty hyperframes cache, populated puppeteer cache — the regression
122149
// scenario from the hf#677 spike.

0 commit comments

Comments
 (0)