mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(media-use): clean failed asset reservations (#2627)
* fix(media-use): clean failed asset reservations * test(media-use): pin installer guidance exactly
This commit is contained in:
@@ -6,14 +6,16 @@ import { track } from "./telemetry.mjs";
|
||||
// needs — so anything below this can't authenticate for free usage at all.
|
||||
export const HEYGEN_MIN_VERSION = "0.3.0";
|
||||
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
|
||||
// bills API credits, so the onboarding steers to OAuth.
|
||||
// bills API credits, so the onboarding steers to OAuth. Keep pipe-to-shell
|
||||
// installer text out of the runtime module; the docs are the safer source of
|
||||
// truth for platform-specific setup.
|
||||
export const HEYGEN_INSTALL_COMMAND =
|
||||
"curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth";
|
||||
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
|
||||
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
|
||||
export const HEYGEN_UPDATE_COMMAND = "heygen update";
|
||||
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
|
||||
|
||||
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. Install: ${HEYGEN_INSTALL_COMMAND}`;
|
||||
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. ${HEYGEN_INSTALL_COMMAND}`;
|
||||
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
|
||||
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { allocateId } from "./manifest.mjs";
|
||||
import { withReservedFile, withReservedFileSync } from "./manifest.mjs";
|
||||
import { freezeUrl } from "./freeze.mjs";
|
||||
import { tokenOverlap } from "./match.mjs";
|
||||
import { buildCube } from "./cube-build.mjs";
|
||||
@@ -183,19 +183,27 @@ export async function freezeLibraryLut(match, { projectDir, type, localOnly = fa
|
||||
// to deterministic buildCube params when offline (--local-only) or if the
|
||||
// download/validation fails, so resolution is never blocked on the network.
|
||||
if (match.url && !localOnly) {
|
||||
const { id, localPath } = allocateId(projectDir, type, ".cube");
|
||||
const fullPath = join(projectDir, localPath);
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
// Download + validate at a .tmp path, then atomically rename. A crash
|
||||
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
|
||||
// at the final path — only a validated cube is ever renamed into place.
|
||||
await freezeUrl(match.url, tmpPath);
|
||||
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
|
||||
return await withReservedFile(
|
||||
projectDir,
|
||||
type,
|
||||
".cube",
|
||||
async ({ id, localPath, fullPath }) => {
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
// Download + validate at a .tmp path, then atomically rename. A crash
|
||||
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
|
||||
// at the final path — only a validated cube is ever renamed into place.
|
||||
await freezeUrl(match.url, tmpPath);
|
||||
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
|
||||
} finally {
|
||||
rmSync(tmpPath, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
if (!match.params) {
|
||||
throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`);
|
||||
}
|
||||
@@ -204,26 +212,25 @@ export async function freezeLibraryLut(match, { projectDir, type, localOnly = fa
|
||||
}
|
||||
|
||||
if (match.params) {
|
||||
const { id, localPath } = allocateId(projectDir, type, ".cube");
|
||||
const fullPath = join(projectDir, localPath);
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
const cube = buildCube(match.params);
|
||||
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
|
||||
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
|
||||
// as the url path above.
|
||||
writeFileSync(tmpPath, cube);
|
||||
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
} catch (err) {
|
||||
rmSync(tmpPath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
return libraryRecord(match, {
|
||||
id,
|
||||
localPath,
|
||||
fullPath,
|
||||
via: match.url ? "params-fallback" : "params",
|
||||
return withReservedFileSync(projectDir, type, ".cube", ({ id, localPath, fullPath }) => {
|
||||
const tmpPath = `${fullPath}.tmp`;
|
||||
try {
|
||||
const cube = buildCube(match.params);
|
||||
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
|
||||
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
|
||||
// as the url path above.
|
||||
writeFileSync(tmpPath, cube);
|
||||
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
|
||||
renameSync(tmpPath, fullPath);
|
||||
return libraryRecord(match, {
|
||||
id,
|
||||
localPath,
|
||||
fullPath,
|
||||
via: match.url ? "params-fallback" : "params",
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpPath, { force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { test } from "node:test";
|
||||
@@ -128,3 +128,33 @@ test("url library entries respect localOnly and freeze through fetch", async ()
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("failed URL library freeze releases its reservation", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-failure-"));
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: { get: () => "12" },
|
||||
body: [Buffer.from("not a cube\n")],
|
||||
});
|
||||
await assert.rejects(
|
||||
freezeLibraryLut(
|
||||
{
|
||||
kind: "library",
|
||||
id: "broken-cdn-look",
|
||||
description: "Broken CDN look",
|
||||
tags: ["broken"],
|
||||
intensity: 1,
|
||||
url: "https://example.com/broken.cube",
|
||||
},
|
||||
{ projectDir, type: "lut" },
|
||||
),
|
||||
/failed to freeze library LUT/,
|
||||
);
|
||||
assert.deepStrictEqual(readdirSync(join(projectDir, ".media/luts")), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,3 +190,40 @@ export function allocateId(projectDir, type, ext) {
|
||||
return { id, localPath };
|
||||
});
|
||||
}
|
||||
|
||||
function reservedFile(projectDir, type, ext) {
|
||||
const allocation = allocateId(projectDir, type, ext);
|
||||
return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
|
||||
}
|
||||
|
||||
function rollbackReservation(reservation) {
|
||||
rmSync(reservation.fullPath, { force: true });
|
||||
}
|
||||
|
||||
// A reservation is committed only when populate returns a non-null value.
|
||||
// Throwing/rejecting or returning null means no usable asset was produced, so
|
||||
// the placeholder must be released. Keeping this transaction beside allocateId
|
||||
// prevents individual provider/cache/LUT paths from forgetting the rollback.
|
||||
export function withReservedFileSync(projectDir, type, ext, populate) {
|
||||
const reservation = reservedFile(projectDir, type, ext);
|
||||
try {
|
||||
const result = populate(reservation);
|
||||
if (result == null) rollbackReservation(reservation);
|
||||
return result;
|
||||
} catch (error) {
|
||||
rollbackReservation(reservation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withReservedFile(projectDir, type, ext, populate) {
|
||||
const reservation = reservedFile(projectDir, type, ext);
|
||||
try {
|
||||
const result = await populate(reservation);
|
||||
if (result == null) rollbackReservation(reservation);
|
||||
return result;
|
||||
} catch (error) {
|
||||
rollbackReservation(reservation);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
@@ -9,6 +17,7 @@ import {
|
||||
findByEntity,
|
||||
nextId,
|
||||
allocateId,
|
||||
withReservedFileSync,
|
||||
normalizePrompt,
|
||||
manifestPath,
|
||||
mediaDir,
|
||||
@@ -164,6 +173,38 @@ function runTests() {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("failed reservation rollback preserves another completed reservation", () => {
|
||||
setup();
|
||||
const kept = withReservedFileSync(tmp, "bgm", ".wav", (reservation) => {
|
||||
writeFileSync(reservation.fullPath, "completed asset");
|
||||
return reservation;
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
withReservedFileSync(tmp, "bgm", ".wav", () => {
|
||||
throw new Error("populate failed");
|
||||
}),
|
||||
/populate failed/,
|
||||
);
|
||||
|
||||
assert.equal(kept.id, "bgm_001");
|
||||
assert.equal(readFileSync(kept.fullPath, "utf8"), "completed asset");
|
||||
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "bgm")), ["bgm_001.wav"]);
|
||||
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_002");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty reservation result releases the placeholder", () => {
|
||||
setup();
|
||||
assert.equal(
|
||||
withReservedFileSync(tmp, "image", ".jpg", () => null),
|
||||
null,
|
||||
);
|
||||
assert.deepStrictEqual(readdirSync(typeDirPath(tmp, "image")), []);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByEntity matches case-insensitively", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
||||
|
||||
Reference in New Issue
Block a user