Skip to content
Draft
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
6 changes: 4 additions & 2 deletions src/core/fileOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export class fileOperations {
return fs.readdirSync(folder);
}

async downloadFile(url: string, targetFile: string) {
async downloadFile(url: string, targetFile: string): Promise<{ headers: Record<string, any> }> {
return await new Promise((resolve, reject) => {
// Ensure the target directory exists
const targetDir = path.dirname(targetFile);
Expand All @@ -305,7 +305,9 @@ export class fileOperations {
const fileWriter = fs
.createWriteStream(targetFile)
.on("finish", () => {
resolve({});
// Surface the response headers so callers can capture image metadata
// (e.g. focal point) that the CDN/blob returns as headers.
resolve({ headers: response.headers });
})
.on("error", (err) => {
reject(err);
Expand Down
35 changes: 35 additions & 0 deletions src/lib/assets/asset-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,38 @@ export function getAssetFilePath(originUrl: string): string {
return "error-parsing-asset-path";
}
}

export interface FocalPoint {
focalX?: string;
focalY?: string;
}

/**
* Extract focal point (focalX/focalY) from an image's response headers.
*
* Agility surfaces focal point via the CDN media endpoint as the
* `agility-focal-x` / `agility-focal-y` headers. These are the only headers we
* trust: the raw blob/S3 object-metadata headers are not a reliable source, so
* an image must be served through the CDN for its focal point to be captured.
*
* Node lower-cases all incoming header names, so we look up lower-cased keys.
*/
export function extractFocalPointFromHeaders(headers: Record<string, any> | undefined | null): FocalPoint {
if (!headers) return {};

const readHeader = (name: string): string | undefined => {
const value = headers[name];
if (value === undefined || value === null) return undefined;
// Node may hand back a string[] for repeated headers; take the first entry.
const raw = Array.isArray(value) ? value[0] : value;
const trimmed = `${raw}`.trim();
return trimmed !== "" ? trimmed : undefined;
};

const focal: FocalPoint = {};
const focalX = readHeader("agility-focal-x");
const focalY = readHeader("agility-focal-y");
if (focalX !== undefined) focal.focalX = focalX;
if (focalY !== undefined) focal.focalY = focalY;
return focal;
}
53 changes: 52 additions & 1 deletion src/lib/assets/tests/asset-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resetState } from "core/state";
import { getAssetFilePath } from "lib/assets/asset-utils";
import { getAssetFilePath, extractFocalPointFromHeaders } from "lib/assets/asset-utils";

beforeEach(() => {
resetState();
Expand Down Expand Up @@ -110,3 +110,54 @@ describe("getAssetFilePath", () => {
});
});
});

// ─── extractFocalPointFromHeaders ─────────────────────────────────────────────

describe("extractFocalPointFromHeaders", () => {
it("reads the CDN focal point headers (agility-focal-x/-y)", () => {
const result = extractFocalPointFromHeaders({
"agility-focal-x": "0.25",
"agility-focal-y": "0.75",
});
expect(result).toEqual({ focalX: "0.25", focalY: "0.75" });
});

it("ignores blob/S3 object-metadata headers (only trusts the CDN headers)", () => {
const result = extractFocalPointFromHeaders({
"x-ms-meta-focalx": "10",
"x-ms-meta-focaly": "20",
"x-amz-meta-focalx": "5",
"x-amz-meta-focaly": "6",
});
expect(result).toEqual({});
});

it("returns only the axis that is present", () => {
const result = extractFocalPointFromHeaders({ "agility-focal-x": "0.4" });
expect(result).toEqual({ focalX: "0.4" });
});

it("ignores blank / whitespace-only header values", () => {
const result = extractFocalPointFromHeaders({ "agility-focal-x": " ", "agility-focal-y": "" });
expect(result).toEqual({});
});

it("trims surrounding whitespace from values", () => {
const result = extractFocalPointFromHeaders({ "agility-focal-x": " 0.3 " });
expect(result).toEqual({ focalX: "0.3" });
});

it("takes the first entry when a header arrives as an array", () => {
const result = extractFocalPointFromHeaders({ "agility-focal-x": ["0.1", "0.9"] as any });
expect(result).toEqual({ focalX: "0.1" });
});

it("returns an empty object when there are no focal headers", () => {
expect(extractFocalPointFromHeaders({ "content-type": "image/png" })).toEqual({});
});

it("returns an empty object for undefined/null headers", () => {
expect(extractFocalPointFromHeaders(undefined)).toEqual({});
expect(extractFocalPointFromHeaders(null)).toEqual({});
});
});
9 changes: 8 additions & 1 deletion src/lib/downloaders/download-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getApiClient, getState, state, getLoggerForGuid, startTimer, endTimer }
import ansiColors from "ansi-colors";
import fs from "fs";
import path from "path";
import { getAssetFilePath } from "../assets/asset-utils";
import { getAssetFilePath, extractFocalPointFromHeaders } from "../assets/asset-utils";
import { getAllChannels } from "../shared/get-all-channels";

export async function downloadAllAssets(guid: string): Promise<void> {
Expand Down Expand Up @@ -173,6 +173,13 @@ export async function downloadAllAssets(guid: string): Promise<void> {
const success = await fileOps.downloadFile(asset.originUrl, assetFilesPath);

if (success) {
// Capture focal point from the image response headers so it can be
// synced through to the target on push (the Media list API does not
// return focal point, only the served image headers do).
const focal = extractFocalPointFromHeaders(success.headers);
if (focal.focalX !== undefined) asset.focalX = focal.focalX;
if (focal.focalY !== undefined) asset.focalY = focal.focalY;

// Write the metadata JSON only after the binary lands on disk, so
// an interrupted pull can't leave a metadata-only (poisoned) entry.
fileOps.exportFiles(`assets`, asset.mediaID.toString(), asset);
Expand Down
53 changes: 53 additions & 0 deletions src/lib/downloaders/tests/download-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,57 @@ describe("downloadAllAssets", () => {
expect(logger.asset.downloaded).toHaveBeenCalledWith(asset);
});
});

describe("focal point capture from response headers", () => {
it("attaches focalX/focalY from the CDN headers onto the persisted asset JSON", async () => {
const asset = makeAsset({ mediaID: 5, fileName: "focal.jpg" });
const logger = makeMockLogger();

jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(logger);
jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({
assetMethods: {
getMediaList: jest.fn().mockResolvedValue({ totalCount: 1, assetMedias: [asset] }),
},
});

(fs.existsSync as jest.Mock).mockReturnValue(false);

downloadFileMock.mockResolvedValue({
headers: { "agility-focal-x": "0.25", "agility-focal-y": "0.75" },
});

await downloadAllAssets("test-guid-u");

expect(exportFilesMock).toHaveBeenCalledWith(
"assets",
asset.mediaID.toString(),
expect.objectContaining({ focalX: "0.25", focalY: "0.75" })
);
});

it("persists the asset without focal fields when the response has no focal headers", async () => {
const asset = makeAsset({ mediaID: 6, fileName: "nofocal.jpg" });
const logger = makeMockLogger();

jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(logger);
jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({
assetMethods: {
getMediaList: jest.fn().mockResolvedValue({ totalCount: 1, assetMedias: [asset] }),
},
});

(fs.existsSync as jest.Mock).mockReturnValue(false);

downloadFileMock.mockResolvedValue({ headers: { "content-type": "image/jpeg" } });

await downloadAllAssets("test-guid-u");

const exportCall = exportFilesMock.mock.calls.find(
(args: any[]) => args[0] === "assets" && args[1] === asset.mediaID.toString()
);
expect(exportCall).toBeDefined();
expect(exportCall![2]).not.toHaveProperty("focalX");
expect(exportCall![2]).not.toHaveProperty("focalY");
});
});
});
35 changes: 33 additions & 2 deletions src/lib/pushers/asset-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ import path from "path";
import { GalleryMapper } from "lib/mappers/gallery-mapper";
import { preflightReport } from "../preflight/preflight-report";

/**
* Build the focal-point query string (`&focalX=..&focalY=..`) for an asset upload.
*
* Focal point is captured from the image response headers during download and
* persisted onto the per-asset JSON (assets/{mediaID}.json). We read it back
* here so the target instance stores the same focal point as the source.
* Returns an empty string when the asset has no focal point.
*/
export function buildFocalPointQuery(media: mgmtApi.Media, sourceGuid: string): string {
try {
const fileOps = new fileOperations(sourceGuid);
const stored = fileOps.readJsonFile(`assets/${media.mediaID}.json`);
if (!stored) return "";

const params: string[] = [];
const focalX = stored.focalX;
const focalY = stored.focalY;
if (focalX !== undefined && focalX !== null && `${focalX}`.trim() !== "") {
params.push(`focalX=${encodeURIComponent(`${focalX}`.trim())}`);
}
if (focalY !== undefined && focalY !== null && `${focalY}`.trim() !== "") {
params.push(`focalY=${encodeURIComponent(`${focalY}`.trim())}`);
}
return params.length > 0 ? `&${params.join("&")}` : "";
} catch {
return "";
}
}

/**
* Extract meaningful error message from API errors
*/
Expand Down Expand Up @@ -258,7 +287,8 @@ async function createAsset(
const baseUrl = (apiClient as any)._options?.baseUrl || determineBaseUrl(targetGuid);
const token = (apiClient as any)._options?.token;

const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}`;
const focalPointQuery = buildFocalPointQuery(media, sourceGuid);
const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}${focalPointQuery}`;
const url = `${baseUrl}/api/v1/instance/${targetGuid}/${apiPath}`;

const response = await axios.post(url, form, {
Expand Down Expand Up @@ -342,7 +372,8 @@ async function updateAsset(
const baseUrl = (apiClient as any)._options?.baseUrl || determineBaseUrl(targetGuid);
const token = (apiClient as any)._options?.token;

const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}`;
const focalPointQuery = buildFocalPointQuery(media, sourceGuid);
const apiPath = `asset/upload?folderPath=${encodeURIComponent(folderPath)}&groupingID=${targetMediaGroupingID}${focalPointQuery}`;
const url = `${baseUrl}/api/v1/instance/${targetGuid}/${apiPath}`;

const response = await axios.post(url, form, {
Expand Down
59 changes: 59 additions & 0 deletions src/lib/pushers/tests/asset-pusher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,62 @@ describe("pushAssets — result shape", () => {
expect(result).toHaveProperty("skipped");
});
});

// ─── buildFocalPointQuery ─────────────────────────────────────────────────────

describe("buildFocalPointQuery", () => {
// Writes the per-asset JSON that download persists, so the query builder can read it back.
function writeAssetJson(mediaID: number, data: Record<string, any>) {
const dir = path.join(tmpDir, "src-guid-u", "assets");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${mediaID}.json`), JSON.stringify(data));
}

it("builds a query string with both focal axes when present", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");
writeAssetJson(1, { mediaID: 1, focalX: "0.25", focalY: "0.75" });

const query = buildFocalPointQuery(makeMedia({ mediaID: 1 }), "src-guid-u");
expect(query).toBe("&focalX=0.25&focalY=0.75");
});

it("includes only the axis that is present", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");
writeAssetJson(2, { mediaID: 2, focalX: "0.4" });

const query = buildFocalPointQuery(makeMedia({ mediaID: 2 }), "src-guid-u");
expect(query).toBe("&focalX=0.4");
});

it("returns an empty string when the asset has no focal point", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");
writeAssetJson(3, { mediaID: 3 });

const query = buildFocalPointQuery(makeMedia({ mediaID: 3 }), "src-guid-u");
expect(query).toBe("");
});

it("ignores blank / whitespace-only focal values", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");
writeAssetJson(4, { mediaID: 4, focalX: " ", focalY: "" });

const query = buildFocalPointQuery(makeMedia({ mediaID: 4 }), "src-guid-u");
expect(query).toBe("");
});

it("returns an empty string when the per-asset JSON does not exist", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");

const query = buildFocalPointQuery(makeMedia({ mediaID: 999 }), "src-guid-u");
expect(query).toBe("");
});

it("url-encodes focal values", async () => {
const { buildFocalPointQuery } = await import("../asset-pusher");
writeAssetJson(5, { mediaID: 5, focalX: "0.5 ", focalY: "0,6" });

const query = buildFocalPointQuery(makeMedia({ mediaID: 5 }), "src-guid-u");
// "0.5" after trim needs no encoding; "0,6" has an encoded comma
expect(query).toBe("&focalX=0.5&focalY=0%2C6");
});
});
Loading