Files
hyperframes/packages/aws-lambda/src/sdk/deploySite.ts
Miguel Ángel da4386f0d0 perf(aws-lambda): cloud renders plan without reading video bytes and start chunks sooner (#6194)
* perf(engine): a remote source's tail decodes run many at a time, not one per half-core

* perf(producer): the plan logs where its extract stage spends its time

* perf(engine): a window that ends inside its source takes its frame count without a decode

* test(engine): pin the tail-decode slot test to the windows that decode

* style(engine): format the tail-probe window check

* test(engine): deferred counts match extraction on a B-frame NTSC source; VFR is not deferred

* style(engine): format the tail-probe tests

* perf(aws-lambda): a site stores each video's ffprobe JSON, and the plan reads it instead of probing

* test(aws-lambda): a site stores and hands out each video's probe

* style(aws-lambda): format the probe-facts upload and tests

* perf(engine): a site's stored probe also holds the final-frame timestamp the plan would probe for

* test(engine): drop an unused import

* test(aws-lambda): the stored probe holds the ffprobe JSON and the final frame

* test(aws-lambda): the stored probe holds the ffprobe JSON and the final frame

* perf(producer): a distributed chunk encodes while it captures

* perf(producer): the audio task logs where its mix spends its time

One line per audio stage: prepare, groups, mix, master, encode and the peak check, in ms.

* perf(engine): locked warmup ticks stop sleeping once the page is ready

* perf(engine): a window reaching a stored video's end takes its frame count from stored facts

* fix(producer): the cloud audio task logs its phases

* perf(engine): stored facts keep the last packet length so late-start windows skip the tail decode

* fix(engine): a probe spawns without waiting when no stored facts are declared

* chore(aws-lambda): shorten comments over the line limit

* fix(engine): warmup keeps pacing after a failed tick; stored final frames check ffprobe major

* test(engine): the recorded ffprobe major matches the build's own banner

* test(engine): the version-banner probes go through a named binary like the other spawns
2026-10-24 14:30:16 +00:00

268 lines
10 KiB
TypeScript

/**
* `deploySite` — upload a project directory to S3 and return a reusable handle.
*
* `renderToLambda` calls this implicitly when no `siteHandle` is passed,
* but exposing it as a standalone verb lets adopters upload a project
* ahead of time and reuse the handle across many renders.
*
* Every file goes into the bucket's shared content store under its SHA-256,
* once: a file any earlier site or render already stored is skipped. The
* site itself is a small `sites/<siteId>/files.json` listing path → hash,
* where `siteId` is derived from a SHA-256 over the project files.
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { S3Client } from "@aws-sdk/client-s3";
import { CONTENT_STORE_RETENTION_DAYS, audioSidecarUri, probeFactsUri } from "../contentStore.js";
import {
buildAudioSidecar,
buildProbeFacts,
hashProjectDir,
listSiteFiles,
wantsAudioSidecar,
wantsProbeFacts,
} from "@hyperframes/producer/distributed";
import {
downloadS3ObjectToFile,
ensureContentStoreObject,
isS3NotFound,
mapConcurrent,
parseS3Uri,
putAudioSidecar,
putProbeFacts,
putContentStoreFile,
s3ObjectExists,
sha256File,
uploadContentAddressedFileToS3,
} from "../s3Transport.js";
import {
isSiteManifestUri,
parseSiteManifest,
serializeSiteManifest,
siteManifestUri,
siteTarballUri,
type SiteFile,
} from "../siteManifest.js";
/** Options for {@link deploySite}. */
export interface DeploySiteOptions {
/** Local project directory containing `index.html` (and any composition assets). */
projectDir: string;
/** S3 bucket the SAM stack / CDK construct provisioned. */
bucketName: string;
/** AWS region for the S3 client. Defaults to the SDK's default chain (env / config / IMDS). */
region?: string;
/**
* Override the content-addressed site id. Useful when the caller has a
* stable external identifier they want to use (e.g. a git SHA); if
* unset, the hash of the project tree picks it.
*/
siteId?: string;
/** Called with a line a user should see, such as audio sidecars skipped for want of ffmpeg. */
onNotice?: (message: string) => void;
/** Injection seam for tests. Production callers leave unset. */
s3?: S3Client;
}
/** Stable handle returned by {@link deploySite}. Pass back to {@link renderToLambda}. */
export interface SiteHandle {
/** Content-addressed (or caller-supplied) identifier; stable across re-uploads of the same tree. */
siteId: string;
/** Bucket the site landed in. Surfaced separately so callers don't have to re-parse `projectS3Uri`. */
bucketName: string;
/** Full `s3://bucket/sites/<siteId>/files.json` URI; pass through to `renderToLambda`. */
projectS3Uri: string;
/** Total size of the project's files in bytes. */
bytes: number;
/** ISO timestamp of this `deploySite` call. */
uploadedAt: string;
/** `false` if every file and the site manifest were already in the bucket. */
uploaded: boolean;
}
/** Handle for a site an earlier {@link deploySite} uploaded; `bytes` and `uploadedAt` are unknown. */
export function siteHandleFromId(bucketName: string, siteId: string): SiteHandle {
const projectS3Uri = siteManifestUri(bucketName, siteId);
return { siteId, bucketName, projectS3Uri, bytes: 0, uploadedAt: "", uploaded: false };
}
/**
* Before a render reuses `site`, re-store any file the content store expired, from `projectDir`.
* Returns the handle to render: a site uploaded before site manifests resolves to its tarball.
*/
export async function ensureSiteStored(
site: SiteHandle,
s3: S3Client,
projectDir?: string,
): Promise<SiteHandle> {
if (!isSiteManifestUri(site.projectS3Uri)) return site;
const { bucket } = parseS3Uri(site.projectS3Uri);
const workdir = mkdtempSync(join(tmpdir(), "hf-site-check-"));
try {
const manifestPath = join(workdir, "files.json");
try {
await downloadS3ObjectToFile(s3, site.projectS3Uri, manifestPath);
} catch (error) {
if (!isS3NotFound(error)) throw error;
return await tarballSite(site, s3, bucket);
}
const files = parseSiteManifest(readFileSync(manifestPath, "utf-8"));
const missing: string[] = [];
await mapConcurrent(files, 16, async ({ path, sha256, sizeBytes }) => {
try {
await ensureContentStoreObject(s3, bucket, sha256, sizeBytes);
} catch (error) {
if ((error as Error).name !== "PLAN_ARTIFACT_MISSING") throw error;
const localPath = projectDir && join(projectDir, path);
if (!localPath || !(await hasContent(localPath, sha256))) return void missing.push(path);
await putContentStoreFile(s3, localPath, bucket, sha256);
}
});
if (missing.length > 0) {
const error = new Error(
`[deploySite] SITE_FILES_MISSING: ${missing.length} of ${files.length} files of site ` +
`${site.siteId} are no longer stored (unused for ${CONTENT_STORE_RETENTION_DAYS} days). ` +
"Run `hyperframes lambda sites create`, or render from the project directory that has them.",
);
error.name = "SITE_FILES_MISSING";
throw error;
}
return site;
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
async function hasContent(localPath: string, sha256: string): Promise<boolean> {
return existsSync(localPath) && (await sha256File(localPath)) === sha256;
}
async function tarballSite(site: SiteHandle, s3: S3Client, bucket: string): Promise<SiteHandle> {
const projectS3Uri = siteTarballUri(bucket, site.siteId);
if (await s3ObjectExists(s3, projectS3Uri)) return { ...site, projectS3Uri };
const error = new Error(
`[deploySite] SITE_NOT_FOUND: site ${site.siteId} has neither ${site.projectS3Uri} nor ` +
`${projectS3Uri}. Run \`hyperframes lambda sites create\` again and use the new site id.`,
);
error.name = "SITE_NOT_FOUND";
throw error;
}
/** Upload `projectDir`'s files to the content store and write the site manifest. */
export async function deploySite(opts: DeploySiteOptions): Promise<SiteHandle> {
const s3 = opts.s3 ?? new S3Client({ region: opts.region });
if (!statSync(opts.projectDir).isDirectory()) {
throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
}
const files = listSiteFiles(opts.projectDir);
const siteId = opts.siteId ?? hashProjectDir(opts.projectDir, files);
const projectS3Uri = siteManifestUri(opts.bucketName, siteId);
const stored: SiteFile[] = [];
let uploaded = false;
await mapConcurrent(files, 8, async (path) => {
const localPath = join(opts.projectDir, path);
const sha256 = await sha256File(localPath);
stored.push({ path, sha256, sizeBytes: statSync(localPath).size });
const outcome = await putContentStoreFile(s3, localPath, opts.bucketName, sha256);
uploaded ||= outcome === "uploaded";
});
stored.sort((a, b) => (a.path < b.path ? -1 : 1));
await storeProbeFacts(s3, opts, stored);
await storeAudioSidecars(s3, opts, stored);
const workdir = mkdtempSync(join(tmpdir(), "hf-deploy-site-"));
try {
const manifestPath = join(workdir, "files.json");
const manifest = serializeSiteManifest(stored);
writeFileSync(manifestPath, manifest);
const outcome = await uploadContentAddressedFileToS3(
s3,
manifestPath,
projectS3Uri,
await sha256File(manifestPath),
"application/json",
);
uploaded ||= outcome === "uploaded";
} finally {
rmSync(workdir, { recursive: true, force: true });
}
return {
siteId,
bucketName: opts.bucketName,
projectS3Uri,
bytes: stored.reduce((sum, file) => sum + file.sizeBytes, 0),
uploadedAt: new Date().toISOString(),
uploaded,
};
}
/**
* Each large video's audio, stored on its own so a render reads kilobytes instead of the whole video.
* Optional: a machine that cannot make them leaves the site exactly as it was, and renders read the video.
*/
async function storeAudioSidecars(
s3: S3Client,
opts: DeploySiteOptions,
files: readonly SiteFile[],
): Promise<void> {
const videos = files.filter((file) => wantsAudioSidecar(file.path, file.sizeBytes));
if (videos.length === 0) return;
const workdir = mkdtempSync(join(tmpdir(), "hf-audio-sidecar-"));
let unavailable = false;
try {
await mapConcurrent(videos, 2, async ({ path, sha256 }) => {
if (unavailable || (await s3ObjectExists(s3, audioSidecarUri(opts.bucketName, sha256))))
return;
const sidecarPath = join(workdir, `${sha256}.m4a`);
const outcome = await buildAudioSidecar(join(opts.projectDir, path), sidecarPath);
if (outcome === "unavailable") unavailable = true;
if (outcome === "built") await putAudioSidecar(s3, sidecarPath, opts.bucketName, sha256);
rmSync(sidecarPath, { force: true });
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
if (unavailable) {
opts.onNotice?.(
"ffmpeg was not found on this machine, so the audio of large videos was not stored on its own: " +
"renders will read the whole video for its audio and take longer. Install ffmpeg and run `sites create` again.",
);
}
}
/**
* Each video's ffprobe JSON, stored beside it, so a render reads kilobytes instead of probing the video.
* Optional: a machine without ffprobe leaves the site as it was, and renders probe the video.
*/
async function storeProbeFacts(
s3: S3Client,
opts: DeploySiteOptions,
files: readonly SiteFile[],
): Promise<void> {
const videos = files.filter((file) => wantsProbeFacts(file.path));
if (videos.length === 0) return;
const workdir = mkdtempSync(join(tmpdir(), "hf-probe-facts-"));
let unavailable = false;
try {
await mapConcurrent(videos, 4, async ({ path, sha256 }) => {
if (unavailable || (await s3ObjectExists(s3, probeFactsUri(opts.bucketName, sha256)))) return;
const factsPath = join(workdir, `${sha256}.json`);
const outcome = await buildProbeFacts(join(opts.projectDir, path), factsPath);
if (outcome === "unavailable") unavailable = true;
if (outcome === "built") await putProbeFacts(s3, factsPath, opts.bucketName, sha256);
rmSync(factsPath, { force: true });
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
if (unavailable) {
opts.onNotice?.(
"ffprobe was not found on this machine, so what renders learn about your videos was not stored: " +
"renders will probe each video themselves and plan more slowly. Install ffmpeg and run `sites create` again.",
);
}
}