diff --git a/src/core/fileOperations.ts b/src/core/fileOperations.ts index ee9b358..293d338 100644 --- a/src/core/fileOperations.ts +++ b/src/core/fileOperations.ts @@ -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 }> { return await new Promise((resolve, reject) => { // Ensure the target directory exists const targetDir = path.dirname(targetFile); @@ -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); diff --git a/src/lib/assets/asset-utils.ts b/src/lib/assets/asset-utils.ts index 6ae7fe0..fef9465 100644 --- a/src/lib/assets/asset-utils.ts +++ b/src/lib/assets/asset-utils.ts @@ -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 | 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; +} diff --git a/src/lib/assets/tests/asset-utils.test.ts b/src/lib/assets/tests/asset-utils.test.ts index f72da63..e3aa30a 100644 --- a/src/lib/assets/tests/asset-utils.test.ts +++ b/src/lib/assets/tests/asset-utils.test.ts @@ -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(); @@ -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({}); + }); +}); diff --git a/src/lib/downloaders/download-assets.ts b/src/lib/downloaders/download-assets.ts index 1caff2a..b016a73 100644 --- a/src/lib/downloaders/download-assets.ts +++ b/src/lib/downloaders/download-assets.ts @@ -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 { @@ -173,6 +173,13 @@ export async function downloadAllAssets(guid: string): Promise { 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); diff --git a/src/lib/downloaders/tests/download-assets.test.ts b/src/lib/downloaders/tests/download-assets.test.ts index cbbb718..5e49cc9 100644 --- a/src/lib/downloaders/tests/download-assets.test.ts +++ b/src/lib/downloaders/tests/download-assets.test.ts @@ -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"); + }); + }); }); diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index 5b49b92..b21e24b 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -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 */ @@ -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, { @@ -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, { diff --git a/src/lib/pushers/tests/asset-pusher.test.ts b/src/lib/pushers/tests/asset-pusher.test.ts index 5d19349..b1f488e 100644 --- a/src/lib/pushers/tests/asset-pusher.test.ts +++ b/src/lib/pushers/tests/asset-pusher.test.ts @@ -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) { + 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"); + }); +});