feat(media): shared geo, path-safety and profile loading helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
parent
a663764ee8
commit
7ccc96ac11
@ -1,28 +1,65 @@
|
||||
import { notImplemented } from "./not-implemented.js";
|
||||
const EARTH_RADIUS_KM = 6371.0088;
|
||||
|
||||
function toRadians(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(
|
||||
_a: { lat: number; lon: number },
|
||||
_b: { lat: number; lon: number },
|
||||
a: { lat: number; lon: number },
|
||||
b: { lat: number; lon: number },
|
||||
): number {
|
||||
return notImplemented("haversineKm");
|
||||
const dLat = toRadians(b.lat - a.lat);
|
||||
const dLon = toRadians(b.lon - a.lon);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(a.lat)) * Math.cos(toRadians(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** Round a coordinate to two decimals (about 1 km) before storage or display. */
|
||||
export function roundCoordinate(_value: number): number {
|
||||
return notImplemented("roundCoordinate");
|
||||
export function roundCoordinate(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** Cosine similarity of two equal-length vectors. */
|
||||
export function cosineSimilarity(_a: ArrayLike<number>, _b: ArrayLike<number>): number {
|
||||
return notImplemented("cosineSimilarity");
|
||||
/** Cosine similarity of two equal-length vectors; 0 when either is all zeros. */
|
||||
export function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number {
|
||||
if (a.length !== b.length) throw new Error("vector length mismatch");
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i] as number;
|
||||
const y = b[i] as number;
|
||||
dot += x * y;
|
||||
normA += x * x;
|
||||
normB += y * y;
|
||||
}
|
||||
if (normA === 0 || normB === 0) return 0;
|
||||
return dot / Math.sqrt(normA * normB);
|
||||
}
|
||||
|
||||
/** Encode Float32 vectors as little-endian bytes for MediaAnalysis.vectors and centroids. */
|
||||
export function encodeVectors(_vectors: number[][]): Uint8Array {
|
||||
return notImplemented("encodeVectors");
|
||||
export function encodeVectors(vectors: ArrayLike<number>[]): Uint8Array {
|
||||
const dim = vectors[0]?.length ?? 0;
|
||||
const bytes = new Uint8Array(vectors.length * dim * 4);
|
||||
const view = new DataView(bytes.buffer);
|
||||
vectors.forEach((vector, row) => {
|
||||
if (vector.length !== dim) throw new Error("vector length mismatch");
|
||||
for (let i = 0; i < dim; i++) view.setFloat32((row * dim + i) * 4, vector[i] as number, true);
|
||||
});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeVectors(_bytes: Uint8Array, _dim: number): Float32Array[] {
|
||||
return notImplemented("decodeVectors");
|
||||
export function decodeVectors(bytes: Uint8Array, dim: number): Float32Array[] {
|
||||
if (dim <= 0 || bytes.byteLength % (dim * 4) !== 0) throw new Error("invalid vector bytes");
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const rows = bytes.byteLength / (dim * 4);
|
||||
const out: Float32Array[] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const vector = new Float32Array(dim);
|
||||
for (let i = 0; i < dim; i++) vector[i] = view.getFloat32((row * dim + i) * 4, true);
|
||||
out.push(vector);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -1,18 +1,88 @@
|
||||
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { PlatformProfile } from "@rakazo/contracts";
|
||||
import { notImplemented } from "./not-implemented.js";
|
||||
import type { ProfileLoadError } from "./profiles.js";
|
||||
import { parseProfiles } from "./profiles.js";
|
||||
|
||||
/** Directory of the profile JSON files shipped with this package. */
|
||||
export const BUILTIN_PROFILES_DIR = new URL("../profiles/", import.meta.url);
|
||||
export const BUILTIN_PROFILES_DIR = fileURLToPath(new URL("../profiles/", import.meta.url));
|
||||
|
||||
/** Read every *.json in a directory; missing directory yields no profiles. */
|
||||
/** Read every *.json in a directory; a missing directory yields no profiles. */
|
||||
export async function loadProfilesFromDir(
|
||||
_dir: string | URL,
|
||||
dir: string,
|
||||
): Promise<{ profiles: PlatformProfile[]; errors: ProfileLoadError[] }> {
|
||||
return notImplemented("loadProfilesFromDir");
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { profiles: [], errors: [] };
|
||||
throw error;
|
||||
}
|
||||
const documents: Array<{ file: string; json: unknown }> = [];
|
||||
const errors: ProfileLoadError[] = [];
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name);
|
||||
try {
|
||||
documents.push({ file: name, json: JSON.parse(await readFile(file, "utf8")) });
|
||||
} catch (error) {
|
||||
errors.push({ file: name, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
const parsed = parseProfiles(documents);
|
||||
return { profiles: parsed.profiles, errors: [...errors, ...parsed.errors] };
|
||||
}
|
||||
|
||||
/** Resolve a path inside root; rejects traversal and symlinks that escape it. */
|
||||
export async function resolveInside(_root: string, _relative: string): Promise<string> {
|
||||
return notImplemented("resolveInside");
|
||||
/** Expand a leading "~/" against the given home directory. */
|
||||
export function expandHome(input: string, home: string): string {
|
||||
return input === "~" ? home : input.startsWith("~/") ? path.join(home, input.slice(2)) : input;
|
||||
}
|
||||
|
||||
export class PathOutsideRootError extends Error {
|
||||
constructor(readonly requested: string) {
|
||||
super("path is outside the allowed root");
|
||||
this.name = "PathOutsideRootError";
|
||||
}
|
||||
}
|
||||
|
||||
function isInside(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `relative` inside `root`. Rejects absolute inputs, traversal, NUL
|
||||
* bytes, and any existing path whose real location (after symlinks) leaves the
|
||||
* real root. A path that does not exist yet is checked through its nearest
|
||||
* existing ancestor.
|
||||
*/
|
||||
export async function resolveInside(root: string, relative: string): Promise<string> {
|
||||
if (relative.includes("\0") || path.isAbsolute(relative))
|
||||
throw new PathOutsideRootError(relative);
|
||||
const realRoot = await realpath(root);
|
||||
const lexical = path.resolve(realRoot, relative);
|
||||
if (!isInside(realRoot, lexical)) throw new PathOutsideRootError(relative);
|
||||
let probe = lexical;
|
||||
for (;;) {
|
||||
try {
|
||||
await lstat(probe);
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
const parent = path.dirname(probe);
|
||||
if (parent === probe) throw new PathOutsideRootError(relative);
|
||||
probe = parent;
|
||||
}
|
||||
}
|
||||
const real = await realpath(probe);
|
||||
if (!isInside(realRoot, real)) throw new PathOutsideRootError(relative);
|
||||
return lexical;
|
||||
}
|
||||
|
||||
/** Like resolveInside for an absolute path that must lie within root. */
|
||||
export async function assertInside(root: string, absolute: string): Promise<string> {
|
||||
if (!path.isAbsolute(absolute)) throw new PathOutsideRootError(absolute);
|
||||
const realRoot = await realpath(root);
|
||||
const relative = path.relative(realRoot, path.resolve(absolute));
|
||||
return resolveInside(realRoot, relative === "" ? "." : relative);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PlatformProfile, PlatformProfileSummary } from "@rakazo/contracts";
|
||||
import { notImplemented } from "./not-implemented.js";
|
||||
import { PlatformProfileSchema } from "@rakazo/contracts";
|
||||
|
||||
export const PROFILE_STALE_DAYS = 90;
|
||||
|
||||
@ -14,25 +14,65 @@ export interface ProfileSet {
|
||||
}
|
||||
|
||||
/** Parse raw JSON documents; invalid ones become errors and are skipped whole. */
|
||||
export function parseProfiles(_documents: Array<{ file: string; json: unknown }>): {
|
||||
export function parseProfiles(documents: Array<{ file: string; json: unknown }>): {
|
||||
profiles: PlatformProfile[];
|
||||
errors: ProfileLoadError[];
|
||||
} {
|
||||
return notImplemented("parseProfiles");
|
||||
const profiles: PlatformProfile[] = [];
|
||||
const errors: ProfileLoadError[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const document of documents) {
|
||||
const parsed = PlatformProfileSchema.safeParse(document.json);
|
||||
if (!parsed.success) {
|
||||
errors.push({
|
||||
file: document.file,
|
||||
message: parsed.error.issues
|
||||
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
.join("; "),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (seen.has(parsed.data.id)) {
|
||||
errors.push({ file: document.file, message: `duplicate profile id ${parsed.data.id}` });
|
||||
continue;
|
||||
}
|
||||
seen.add(parsed.data.id);
|
||||
profiles.push(parsed.data);
|
||||
}
|
||||
return { profiles, errors };
|
||||
}
|
||||
|
||||
/** Private profiles override builtin ones with the same id. */
|
||||
export function mergeProfiles(
|
||||
_builtin: PlatformProfile[],
|
||||
_private: PlatformProfile[],
|
||||
builtin: PlatformProfile[],
|
||||
privateProfiles: PlatformProfile[],
|
||||
): ProfileSet {
|
||||
return notImplemented("mergeProfiles");
|
||||
const profiles: ProfileSet["profiles"] = new Map();
|
||||
for (const profile of builtin) profiles.set(profile.id, { profile, origin: "builtin" });
|
||||
for (const profile of privateProfiles) profiles.set(profile.id, { profile, origin: "private" });
|
||||
return { profiles, errors: [] };
|
||||
}
|
||||
|
||||
export function isProfileStale(profile: PlatformProfile, now: Date): boolean {
|
||||
const reviewed = Date.parse(`${profile.reviewedAt}T00:00:00Z`);
|
||||
return now.getTime() - reviewed > PROFILE_STALE_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function summarizeProfile(
|
||||
_profile: PlatformProfile,
|
||||
_origin: "builtin" | "private",
|
||||
_now: Date,
|
||||
profile: PlatformProfile,
|
||||
origin: "builtin" | "private",
|
||||
now: Date,
|
||||
): PlatformProfileSummary {
|
||||
return notImplemented("summarizeProfile");
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
kind: profile.kind,
|
||||
origin,
|
||||
unverified: profile.unverified,
|
||||
stale: isProfileStale(profile, now),
|
||||
aspectRatios: profile.video.aspectRatios,
|
||||
hardMinMs: profile.duration.hardMinMs,
|
||||
hardMaxMs: profile.duration.hardMaxMs,
|
||||
recommendedMs: profile.duration.recommended,
|
||||
};
|
||||
}
|
||||
|
||||
144
packages/media/src/shared.test.ts
Normal file
144
packages/media/src/shared.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cosineSimilarity,
|
||||
decodeVectors,
|
||||
encodeVectors,
|
||||
haversineKm,
|
||||
roundCoordinate,
|
||||
} from "./geo.js";
|
||||
import {
|
||||
assertInside,
|
||||
expandHome,
|
||||
loadProfilesFromDir,
|
||||
PathOutsideRootError,
|
||||
resolveInside,
|
||||
} from "./node.js";
|
||||
import { mergeProfiles, parseProfiles, summarizeProfile } from "./profiles.js";
|
||||
|
||||
const profileJson = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
name: `Profile ${id}`,
|
||||
kind: "free_social",
|
||||
reviewedAt: "2026-09-01",
|
||||
video: {
|
||||
aspectRatios: ["9:16"],
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
codec: "h264",
|
||||
maxBitrateKbps: 8000,
|
||||
maxFileSizeMb: 500,
|
||||
},
|
||||
duration: { hardMinMs: 1000, hardMaxMs: 60000, recommended: [15000] },
|
||||
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 10 },
|
||||
captions: { recommended: true },
|
||||
audio: {},
|
||||
text: { captionMaxChars: 2200, hashtagLimit: 5, linkPolicy: "bio_only" },
|
||||
content: { thresholds: { explicit_nudity: 0.2 } },
|
||||
disclosure: { aiAlteredLabelRequired: true },
|
||||
consent: { requirePerformerConsent: false, requireAgeVerification: false },
|
||||
export: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("geo", () => {
|
||||
it("measures distance and rounds coordinates", () => {
|
||||
expect(haversineKm({ lat: 52.52, lon: 13.405 }, { lat: 48.137, lon: 11.575 })).toBeCloseTo(
|
||||
504,
|
||||
-1,
|
||||
);
|
||||
expect(haversineKm({ lat: 1, lon: 1 }, { lat: 1, lon: 1 })).toBe(0);
|
||||
expect(roundCoordinate(52.51678)).toBe(52.52);
|
||||
});
|
||||
|
||||
it("computes cosine similarity and round-trips vectors", () => {
|
||||
expect(cosineSimilarity([1, 0], [1, 0])).toBe(1);
|
||||
expect(cosineSimilarity([1, 0], [0, 1])).toBe(0);
|
||||
expect(cosineSimilarity([0, 0], [1, 1])).toBe(0);
|
||||
expect(() => cosineSimilarity([1], [1, 2])).toThrow();
|
||||
const decoded = decodeVectors(
|
||||
encodeVectors([
|
||||
[1, 2.5],
|
||||
[-3, 0],
|
||||
]),
|
||||
2,
|
||||
);
|
||||
expect(Array.from(decoded[1] ?? [])).toEqual([-3, 0]);
|
||||
expect(() => decodeVectors(new Uint8Array(6), 2)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("profiles", () => {
|
||||
it("skips invalid and duplicate documents whole", () => {
|
||||
const result = parseProfiles([
|
||||
{ file: "a.json", json: profileJson("a") },
|
||||
{ file: "b.json", json: { id: "b" } },
|
||||
{ file: "a2.json", json: profileJson("a") },
|
||||
{ file: "c.json", json: profileJson("c", { duration: { hardMinMs: 9, hardMaxMs: 1 } }) },
|
||||
]);
|
||||
expect(result.profiles.map((p) => p.id)).toEqual(["a"]);
|
||||
expect(result.errors.map((e) => e.file)).toEqual(["b.json", "a2.json", "c.json"]);
|
||||
});
|
||||
|
||||
it("lets private profiles override builtin ones and flags stale ones", () => {
|
||||
const builtin = parseProfiles([{ file: "x", json: profileJson("shared") }]).profiles;
|
||||
const own = parseProfiles([
|
||||
{ file: "y", json: profileJson("shared", { name: "Mine" }) },
|
||||
]).profiles;
|
||||
const merged = mergeProfiles(builtin, own);
|
||||
expect(merged.profiles.get("shared")?.origin).toBe("private");
|
||||
expect(merged.profiles.get("shared")?.profile.name).toBe("Mine");
|
||||
const summary = summarizeProfile(builtin[0]!, "builtin", new Date("2027-01-15T00:00:00Z"));
|
||||
expect(summary.stale).toBe(true);
|
||||
expect(summarizeProfile(builtin[0]!, "builtin", new Date("2026-09-20T00:00:00Z")).stale).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node helpers", () => {
|
||||
let root: string;
|
||||
let outside: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "media-root-"));
|
||||
outside = await mkdtemp(path.join(tmpdir(), "media-outside-"));
|
||||
await mkdir(path.join(root, "a"));
|
||||
await writeFile(path.join(root, "a", "f.txt"), "x");
|
||||
await symlink(outside, path.join(root, "escape"));
|
||||
await writeFile(path.join(root, "good.json"), JSON.stringify(profileJson("good")));
|
||||
await writeFile(path.join(root, "broken.json"), "{");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves paths inside the root and rejects escapes", async () => {
|
||||
expect(await resolveInside(root, "a/f.txt")).toMatch(/a\/f\.txt$/);
|
||||
expect(await resolveInside(root, "a/new/file.mp4")).toMatch(/a\/new\/file\.mp4$/);
|
||||
await expect(resolveInside(root, "../x")).rejects.toBeInstanceOf(PathOutsideRootError);
|
||||
await expect(resolveInside(root, "/etc/passwd")).rejects.toBeInstanceOf(PathOutsideRootError);
|
||||
await expect(resolveInside(root, "escape/secret")).rejects.toBeInstanceOf(PathOutsideRootError);
|
||||
await expect(resolveInside(root, "a/\0x")).rejects.toBeInstanceOf(PathOutsideRootError);
|
||||
await expect(assertInside(root, path.join(outside, "x"))).rejects.toBeInstanceOf(
|
||||
PathOutsideRootError,
|
||||
);
|
||||
expect(await assertInside(root, path.join(root, "a"))).toMatch(/\/a$/);
|
||||
});
|
||||
|
||||
it("loads profile directories and reports broken files", async () => {
|
||||
const loaded = await loadProfilesFromDir(root);
|
||||
expect(loaded.profiles.map((p) => p.id)).toEqual(["good"]);
|
||||
expect(loaded.errors.map((e) => e.file)).toEqual(["broken.json"]);
|
||||
expect(await loadProfilesFromDir(path.join(root, "missing"))).toEqual({
|
||||
profiles: [],
|
||||
errors: [],
|
||||
});
|
||||
expect(expandHome("~/x", "/home/u")).toBe("/home/u/x");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user