Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ describe("compileTimingAttrs", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
const { html: compiled, unresolved } = compileTimingAttrs(html);

expect(compiled).toContain('data-end="7"');
expect(compiled).not.toContain("data-has-audio");
expect(unresolved).toHaveLength(0);
});

it("preserves explicit video audio declarations", () => {
const html =
'<video id="v1" src="a.mp4" data-start="2" data-duration="5" data-has-audio="true">';
const { html: compiled, unresolved } = compileTimingAttrs(html);

expect(compiled).toContain('data-end="7"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(0);
Expand Down Expand Up @@ -40,7 +50,7 @@ describe("compileTimingAttrs", () => {
const { html: compiled, unresolved } = compileTimingAttrs(html);

expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-has-audio="true"');
expect(compiled).not.toContain("data-has-audio");
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("hf-video-0");
expect(unresolved[0].tagName).toBe("video");
Expand Down
8 changes: 1 addition & 7 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* Guarantees every timed element gets:
* - id on media elements when missing
* - data-end (computed from data-start + data-duration when possible)
* - data-has-audio="true" on <video> elements
*
* For elements without data-duration (e.g. videos relying on source duration),
* this compiler identifies them as "unresolved" so the caller can provide
Expand Down Expand Up @@ -101,19 +100,14 @@ function compileTag(
}
}

// 2. Add data-has-audio="true" to <video> elements
if (isVideo && !hasAttr(result, "data-has-audio")) {
result = injectAttr(result, "data-has-audio", "true");
}

return { tag: result, unresolved };
}

/**
* Compile timing attributes in HTML.
*
* Phase 1 (static): Adds data-end where data-duration exists,
* adds data-has-audio on videos.
* preserves explicit video audio declarations.
*
* Returns the compiled HTML and a list of elements that could not be
* resolved statically (missing data-duration). The caller should resolve
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export {
ENCODER_PRESETS,
getEncoderPreset,
type GpuEncoder,
type MuxOptions,
} from "./services/chunkEncoder.js";
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";

Expand Down
60 changes: 59 additions & 1 deletion packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { describe, it, expect, vi } from "vitest";
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import {
ENCODER_PRESETS,
getEncoderPreset,
buildEncoderArgs,
muxVideoWithAudio,
} from "./chunkEncoder.js";

vi.mock("../utils/runFfmpeg.js", () => ({
runFfmpeg: vi.fn(async () => ({
success: true,
exitCode: 0,
stderr: "",
durationMs: 12,
})),
formatFfmpegError: vi.fn((exitCode: number | null, stderr: string) =>
exitCode === null ? stderr : `FFmpeg exited with code ${exitCode}`,
),
}));

describe("ENCODER_PRESETS", () => {
it("has draft, standard, and high presets", () => {
Expand Down Expand Up @@ -78,6 +96,46 @@ describe("getEncoderPreset", () => {
});
});

describe("muxVideoWithAudio", () => {
it("clamps mux output to the encoded video frame duration when provided", async () => {
const mockedRunFfmpeg = vi.mocked(runFfmpeg);
mockedRunFfmpeg.mockClear();

await muxVideoWithAudio("video-only.mp4", "audio.aac", "output.mp4", undefined, {
ffmpegProcessTimeout: 1234,
durationSeconds: 500 / 30,
});

expect(mockedRunFfmpeg).toHaveBeenCalledTimes(1);
const [args, options] = mockedRunFfmpeg.mock.calls[0] ?? [];
expect(args).toEqual(
expect.arrayContaining([
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-t",
String(500 / 30),
]),
);
expect(args).not.toContain("-shortest");
expect(options).toEqual(expect.objectContaining({ timeout: 1234 }));
});

it("falls back to shortest-stream muxing when no target duration is known", async () => {
const mockedRunFfmpeg = vi.mocked(runFfmpeg);
mockedRunFfmpeg.mockClear();

await muxVideoWithAudio("video-only.mp4", "audio.aac", "output.mp4");

const [args] = mockedRunFfmpeg.mock.calls[0] ?? [];
expect(args).toContain("-shortest");
expect(args).not.toContain("-t");
});
});

describe("buildEncoderArgs anti-banding", () => {
const baseOptions = { fps: 30, width: 1920, height: 1080 };

Expand Down
17 changes: 14 additions & 3 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ export function buildEncoderArgs(
return args;
}

export interface MuxOptions extends Partial<Pick<EngineConfig, "ffmpegProcessTimeout">> {
durationSeconds?: number;
}

export async function encodeFramesFromDir(
framesDir: string,
framePattern: string,
Expand Down Expand Up @@ -494,14 +498,14 @@ export async function muxVideoWithAudio(
audioPath: string,
outputPath: string,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
config?: MuxOptions,
): Promise<MuxResult> {
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });

const isWebm = outputPath.endsWith(".webm");
const isMov = outputPath.endsWith(".mov");
const args = ["-i", videoPath, "-i", audioPath, "-c:v", "copy"];
const args = ["-i", videoPath, "-i", audioPath, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"];

if (isWebm) {
args.push("-c:a", "libopus", "-b:a", "128k");
Expand All @@ -510,7 +514,14 @@ export async function muxVideoWithAudio(
} else {
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
}
args.push("-shortest", "-y", outputPath);

const durationSeconds = config?.durationSeconds;
if (durationSeconds !== undefined && Number.isFinite(durationSeconds) && durationSeconds > 0) {
args.push("-t", String(durationSeconds));
} else {
args.push("-shortest");
}
args.push("-y", outputPath);

const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const result = await runFfmpeg(args, { signal, timeout: processTimeout });
Expand Down
1 change: 1 addition & 0 deletions packages/producer/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export {
type EncoderOptions,
type EncodeResult,
type MuxResult,
type MuxOptions,
type GpuEncoder,
} from "@hyperframes/engine";
5 changes: 0 additions & 5 deletions packages/producer/src/services/compilationTester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,6 @@ function validateElementTiming(element: CompiledElement, label: string): string[
}
}

// Video-specific: require data-has-audio
if (element.tagName === "video" && element.dataHasAudio === undefined) {
errors.push(`${label} [${element.id}]: missing data-has-audio attribute`);
}

return errors;
}

Expand Down
45 changes: 43 additions & 2 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,51 @@ describe("detectShaderTransitionUsage", () => {
});
});

describe("video audio declarations", () => {
it("does not synthesize audible video audio for muted visual tracks", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-muted-video-audio-"));
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html>
<html>
<body>
<div data-composition-id="main" data-width="640" data-height="360" data-duration="2">
<video
id="visual"
src="clip.mp4"
muted
playsinline
data-start="0"
data-duration="2"
></video>
<audio id="mix" src="mix.wav" data-start="0" data-duration="2"></audio>
</div>
</body>
</html>`,
);

const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);

expect(compiled.videos).toEqual([
expect.objectContaining({
id: "visual",
hasAudio: false,
}),
]);
expect(compiled.audios).toEqual([
expect.objectContaining({
id: "mix",
type: "audio",
}),
]);
expect(compiled.html).not.toContain('data-has-audio="true"');
});
});

describe("template-wrapped sub-composition media offsets", () => {
function writeTemplateWrappedProject(
hostAttrs: string,
mediaAttrs: string = 'data-start="0" data-duration="4"',
mediaAttrs: string = 'data-start="0" data-duration="4" data-has-audio="true"',
extraMediaMarkup: string = "",
): {
projectDir: string;
Expand Down Expand Up @@ -613,7 +654,7 @@ describe("template-wrapped sub-composition media offsets", () => {
it("offsets scene-local media in compositions that start much later on the timeline", async () => {
const { projectDir, indexPath } = writeTemplateWrappedProject(
'data-start="20" data-duration="6" data-width="640" data-height="360"',
'data-start="1.5" data-duration="4"',
'data-start="1.5" data-duration="4" data-has-audio="true"',
);

const compiled = await compileForRender(projectDir, indexPath, projectDir);
Expand Down
36 changes: 20 additions & 16 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2250,24 +2250,24 @@ export async function executeRenderJob(
videoMetadataHints = collectVideoMetadataHints(extractionResult.extracted);
perfStages.videoExtractMs = Date.now() - stage2Start;

// Auto-detect audio from video files via ffprobe metadata
// Only explicitly audible videos contribute audio. Muted visual video
// tracks often have embedded scratch/source audio while the composition
// uses a separate <audio> element for the final mix.
const existingAudioSrcs = new Set(composition.audios.map((a) => a.src));
for (const ext of extractionResult.extracted) {
if (ext.metadata.hasAudio) {
const video = composition.videos.find((v) => v.id === ext.videoId);
if (video && !existingAudioSrcs.has(video.src)) {
composition.audios.push({
id: `${video.id}-audio`,
src: video.src,
start: video.start,
end: video.end,
mediaStart: video.mediaStart,
layer: 0,
volume: 1.0,
type: "video",
});
existingAudioSrcs.add(video.src);
}
const video = composition.videos.find((v) => v.id === ext.videoId);
if (video?.hasAudio && ext.metadata.hasAudio && !existingAudioSrcs.has(video.src)) {
composition.audios.push({
id: `${video.id}-audio`,
src: video.src,
start: video.start,
end: video.end,
mediaStart: video.mediaStart,
layer: 0,
volume: 1.0,
type: "video",
});
existingAudioSrcs.add(video.src);
}
}
} else {
Expand Down Expand Up @@ -3667,6 +3667,10 @@ export async function executeRenderJob(
audioOutputPath,
outputPath,
abortSignal,
{
ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
durationSeconds: totalFrames / job.config.fps,
},
);
assertNotAborted();
if (!muxResult.success) {
Expand Down
4 changes: 0 additions & 4 deletions packages/producer/src/services/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,6 @@ function compileTag(
}
}

if (isVideo && !hasAttr(result, "data-has-audio")) {
result = injectAttr(result, "data-has-audio", "true");
}

return { tag: result, unresolved };
}

Expand Down
36 changes: 19 additions & 17 deletions packages/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,21 @@ const THUMBNAIL_CACHE_VERSION = "v2";
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
// Lazy-load the bundler via Vite's SSR module loader
let _bundler: ((dir: string) => Promise<string>) | null = null;
let _producerModulePromise: Promise<{
createRenderJob: (config: {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: string;
}) => unknown;
executeRenderJob: (
job: unknown,
projectDir: string,
outputPath: string,
onProgress?: (job: { progress: number; currentStage?: string }) => void,
) => Promise<void>;
}> | null = null;
let _producerModuleLoader:
| (() => Promise<{
createRenderJob: (config: {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: string;
}) => unknown;
executeRenderJob: (
job: unknown,
projectDir: string,
outputPath: string,
onProgress?: (job: { progress: number; currentStage?: string }) => void,
) => Promise<void>;
}>)
| null = null;
const getBundler = async () => {
if (!_bundler) {
try {
Expand All @@ -80,8 +82,8 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
};

const getProducerModule = async () => {
if (!_producerModulePromise) {
_producerModulePromise = createRetryingModuleLoader(async () => {
if (!_producerModuleLoader) {
_producerModuleLoader = createRetryingModuleLoader(async () => {
const { built } = ensureProducerDist({
studioDir: __dirname,
env: process.env,
Expand All @@ -93,9 +95,9 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
}
const producerPkg = "@hyperframes/producer";
return await import(/* @vite-ignore */ producerPkg);
})();
});
}
return _producerModulePromise();
return _producerModuleLoader();
};

return {
Expand Down
Loading