import { MakeupEngine } from "./MakeupEngine";
import { SdkAnalytics } from "./Analytics";
import { updateMakeupDebounce2 } from "./Debounce";
import { GlobalEngine } from "./GlobalEngine";
import { exists } from "./Utility/exists";
import Screen from "./Utility/Screen";
import {
expandProductUsagePatternFetch,
tryToFetchshadeColorProductId,
} from "./CmsDataFetching";
import Color from "./Utility/Color";
/** @typedef {import("./FeatureTemplate").FeatureTemplate} FeatureTemplate */
/** @typedef {import("./FeatureTemplate").FinishTemplate} FinishTemplate */
/** @typedef {import("./FeatureTemplate").BoundingBox} BoundingBox */
/** @typedef {import("./FeatureTemplate").Effect} Effect */
/** @typedef {import("./Analytics").EffectDataPayload} EffectDataPayload */
/**
* Expanded product usage pattern retrieved from the cms.
* Additional data not required for rendering has been omitted for simplicity.
* For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
* @typedef {Object} ProductUsagePattern
* @property {number} id - Unique identifier for the usage pattern.
* @property {number | undefined} product - Unique identifier for the product of this usage pattern.
* @property {UsagePatternArea[]} usagePatternAreas - Array of usage pattern areas.
*/
/**
* Usage pattern area retrieved from the cms.
* Usage pattern defines what application area to render.
* Additional data not required for rendering has been omitted for simplicity.
* For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
* @typedef {Object} UsagePatternArea
* @property {string} applicationArea - Name of the application area this should equal one {@link FeatureTemplate#name|FeatureTemplate.name} found in {@link MakeupEngine#GetFeatures|MakeupEngine.GetFeatures}
* @property {ShadeColor} shadeColor - ShadeColor defined for a specific usage pattern area
*/
/**
* Shade color retrieved from the cms.
* Shade color contains the complete color information for rendering of a specific {@link Effect}
* Additional data not required for rendering has been omitted for simplicity.
* For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
* @typedef {Object} ShadeColor
* @property {number | undefined} id - Unique identifier of the shade color.
* @property {Finish} finish - Finish used to enhance the shade color.
* @property {number} colorOpacity - Opacity level of the color. Range is from [0, 100].<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
* @property {number} finishOpacity - Opacity level of the finish. Range is from [0, 100].<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
* @property {string} rendering - Hex string representing the color. The format is "0xffffff".<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
*/
/**
* Finish retrieved from the cms.
* Contains information for rendering a specific {@link FinishTemplate} from {@link MakeupEngine}
* Additional data not required for rendering has been omitted for simplicity.
* For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
* @typedef {Object} Finish
* @property {string} name - name of the finish, this value should equal any {@link FinishTemplate#name| finish.name} from {@link MakeupEngine#GetFinishes|MakeupEngine.GetFinishes}
*/
/**
* Configuration
* @typedef {Object} UpdateMakeupConfig
* @property {BoundingBox} [boundingBox = undefined] - New bounding box to be set, if undefined the previous value is retained
* @property {number} [analyticsDebounceTime = 300] - Define how long the debounce delay for analytics should be
*/
/**
* Configuration required to access a specific product usage pattern.
* @typedef {Object} FetchProductUsagePatternConfiguration
* @property {number} id - Unique identifier for the product usage pattern.
* @property {string} languageCode - The appropriate language code of the specific product usage pattern.
*/
export class CmsMakeupEngine {
/**
* @param {FeatureTemplate[]} features - predefined feature templates repository
* @param {FinishTemplate[]} finishes - predefined finish templates repository
* @param {SdkAnalytics?} makeupEngineAnalytics - This engine sends data to analytics endpoint if this variable is present
*/
constructor(features, finishes, makeupEngineAnalytics) {
// since i check if instance is here, and this will absolutely run before any member, i'll not check there.
// it's ok if instance is never invalidated at later point, which it shouldn't be
exists(GlobalEngine.instance, "Engine is not initialized.");
this.globalEngine = /** @type {GlobalEngine} */ (GlobalEngine.instance);
this.features = features;
this.finishes = finishes;
this.makeupEngineAnalytics = makeupEngineAnalytics;
/**
* One pup is a group of effects. In most cases it's one, but we can have patterns of multiple effects, eg. lipstick ombre = lipstick inner + lipstick outer
* @type {Map<number, {pup: ProductUsagePattern, effects: Effect[]}>}
*/
this.effectsRecord = new Map();
}
// #region ----- Others -------
/**
* Returns a DOM element which displays the frame.
*/
GetView() {
return Screen.renderer.domElement;
}
/**
* Registers a callback function which is invoked when rendering of a frame passed to FrameProvider is completed and the frame is rendered.
* <br/>
* It is advised to use this function to synchronize passing of new frames to FrameProvider with processing of current frame.
* @param {VoidFunction} callback - Callback that will be invoked on frame completion
*/
onFrameRendered(callback) {
return this.globalEngine.onIterationComplete(callback);
}
/**
* Helper method to chain multiple fetch calls and generate required Expanded ProductUsagePattern for rendering using {@link CmsMakeupEngine#CreateMakeup|CmsMakeupEngine.CreateMakeup} in the background
*
* @deprecated This helper method is increasing the library responsibilities and coupling to web service without any apparent benefits.
*
* @async
* @throws {Error}
* @param {FetchProductUsagePatternConfiguration} config - Configuration required to access a specific product usage pattern.
* @returns {Promise<ProductUsagePattern>}
*/
async FetchProductUsagePattern(config) {
const { id, languageCode } = config;
const url = this.makeupEngineAnalytics?.cmsURL;
const accessToken = this.makeupEngineAnalytics?.accessToken;
if (!url || !accessToken) {
throw new Error(
"Please define cmsURL or accessToken to use FetchProductUsagePattern method.",
);
}
return expandProductUsagePatternFetch(
{
url,
accessToken,
languageCode,
},
id,
);
}
/**
* @param {UsagePatternArea} upa
* @returns {{feature: FeatureTemplate, finish: FinishTemplate}}
*/
#UsagePatternAreaToTemplates(upa) {
let feature = this.features.find((f) => f.name === upa.applicationArea);
exists(
feature,
"Feature template not found for AplicationArea " +
upa.applicationArea,
);
// This tries to find a fitting element to apply rendering color to effect.
// If found, it will create a copy of template with mutated color.
const colorElementIdx = feature.elements.findIndex(
(e) => e.name === "Color",
);
if (colorElementIdx >= 0) {
/** @type {FeatureTemplate} */
const copy = JSON.parse(JSON.stringify(feature));
const colorElement = copy.elements[colorElementIdx];
colorElement.color = hexToColor(
upa.shadeColor.rendering,
upa.shadeColor.colorOpacity / 100,
);
feature = copy;
}
let finish = this.finishes.find(
(f) => f.name === upa.shadeColor.finish.name,
);
exists(
finish,
"Finish template not found for " + upa.shadeColor.finish.name,
);
// Creates a copy to not dirty base template data
finish = {
...finish,
transparency: upa.shadeColor.finishOpacity / 100,
};
return { feature, finish };
}
// #endregion
// #region ----- Analytics ------
/**
*
* @param {UsagePatternArea} upa
* @param {Effect} eff
* @returns {Omit<EffectDataPayload, "session_uuid">}
*/
#upaAndEffectToSimulationPayload(upa, eff) {
return {
application_area_name: upa.applicationArea,
bounding_box: eff.boundingBox,
color_opacity: Math.round(upa.shadeColor.colorOpacity),
effect: eff.id,
finish_name: upa.shadeColor.finish.name,
finish_opacity: Math.round(upa.shadeColor.finishOpacity),
rendering_hex: upa.shadeColor.rendering,
session_product_interaction: undefined,
shade_color: upa.shadeColor.id,
};
}
/**
* This is fire-and-forget function.
*
* MODIFIES - It may update `pup` parameter asynchronously at some later point.
*
* @param {ProductUsagePattern} pup
* @param {UpaEffectPair[]} upasWithEffects - usually can be infered from record only, but in case of Update, it's racing
*/
async #sendSimulated(pup, upasWithEffects) {
if (!pup.product) {
await this.#tryToFindProductToBackfillData(pup);
}
if (this.makeupEngineAnalytics) {
const productPayload =
pup.product
? {
type: /** @type {"PRODUCT_SIMULATED"} */ (
"PRODUCT_SIMULATED"
),
productId: pup.product,
}
: null;
this.makeupEngineAnalytics.sendProductAndEffects(
productPayload,
upasWithEffects
// I leave this filter as a reminder, I'm not including it intentionaly
// "being displayed/active" is based on idea that either all constituent effects are displayed,
// or none are - and that's what implicitly defines if entire product is displayed.
// There should never be a case where it's partially visible - program is likely be in an invalid state.
// .filter(([, eff]) => this.globalEngine.IsMakeupDisplayed(eff))
.map(({upa, effect}) => ({
type: "EFFECT_SIMULATED",
payload: this.#upaAndEffectToSimulationPayload(
upa,
effect,
),
})),
);
}
}
/**
* This is fire-and-forget function.
*
* MODIFIES - It may update `pup` parameter asynchronously at some later point.
*
* @param {ProductUsagePattern} pup
* @param {UpaEffectPair[]} upasWithEffects - usually can be infered from record only, but in case of Update, it's racing
*/
async #sendDisabled(pup, upasWithEffects) {
if (!pup.product) {
await this.#tryToFindProductToBackfillData(pup);
}
if (this.makeupEngineAnalytics) {
const productPayload =
pup.product
? {
type: /** @type {"PRODUCT_DISABLED"} */ (
"PRODUCT_DISABLED"
),
productId: pup.product,
}
: null;
this.makeupEngineAnalytics.sendProductAndEffects(
productPayload,
upasWithEffects
// I leave this filter as a reminder, I'm not including it intentionaly
// "being displayed/active" is based on idea that either all constituent effects are displayed,
// or none are - and that's what implicitly defines if entire product is displayed.
// There should never be a case where it's partially visible - program is likely be in an invalid state.
// .filter(([, eff]) => this.globalEngine.IsMakeupDisplayed(eff))
.map(({upa, effect}) => ({
type: "EFFECT_DISABLED",
payload: this.#upaAndEffectToSimulationPayload(
upa,
effect,
),
})),
);
}
}
/**
* MODIFIES parameter
*
* Tries to backfill product id data if it's missing or not provided.
*
* This is done for https://visagetechnologies.atlassian.net/browse/BEA-6931 2nd AC only.
*
* @param {ProductUsagePattern} pup If missing product's id, it will try to be modified.
* @returns {Promise<void>}
*/
async #tryToFindProductToBackfillData(pup) {
const shadeColorId = pup.usagePatternAreas.at(0)?.shadeColor.id;
// shortcircuit as there is nothing to backfill
if (!shadeColorId) return undefined;
try {
const url = this.makeupEngineAnalytics?.cmsURL;
const accessToken = this.makeupEngineAnalytics?.accessToken;
if (!url || !accessToken) {
throw new Error(
"Please define cmsURL or accessToken to use FetchProductUsagePattern method.",
);
}
const productId = await tryToFetchshadeColorProductId(
{
url,
accessToken,
},
shadeColorId
);
if (productId) {
pup.product = productId;
}
} catch {
// Swallow up error if the data back-fill fails
// It is best effort, but there is no guarantee or reason it should work reliably in any way
return Promise.resolve()
}
}
// #endregion
// #region ----- Effects ------
/**
* Checks wether the current product usage pattern is currently displayed
*
* @param {ProductUsagePattern} productUsagePattern
* @returns {boolean}
*/
IsMakeupDisplayed(productUsagePattern) {
// It doesn't matter what we receive (as this is user's input and can be anything),
// We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
const pupId = productUsagePattern.id;
if (!this.effectsRecord.has(pupId)) return false;
const records = this.effectsRecord.get(pupId);
// If effects not found, it's obviously not displayed
if (!records) return false;
// There is a special case that's valid value in the codebase at this point - empty array.
// It's may be up for discussion if it's displayed if nothing is displayed, on the other hand
// it's correct that empty set is allways displayed. This may not even be an issue and this is good
// enough for now at least.
return records.effects.every((eff) =>
this.globalEngine.IsMakeupDisplayed(eff),
);
}
/**
* Create a record inside the engine for rendering a {@link ProductUsagePattern|expanded product usage pattern} retrieved from the CMS.<br/>
* The expanded pattern can be generated through multiple fetch calls to the api: {@link https://cms.arbelle.ai/docs/v2.0}<br/>
* To simplify the process use the {@link CmsMakeupEngine#FetchProductUsagePattern|CmsMakeupEngine.FetchProductUsagePattern} helper method.<br/>
* After the promise is resolved reuse the {@link ProductUsagePattern|expanded product usage pattern} in other methods to:
* <ol>
* <li>{@link CmsMakeupEngine#DisplayMakeup| Display} the created effect since created effects start off hidden</li>
* <li>{@link CmsMakeupEngine#UpdateMakeup| Update} the created effect to change colors, opacities...</li>
* <li>{@link CmsMakeupEngine#HideMakeup| Hide} the created effect if it shouldn't be used anymore </li>
* <li>{@link CmsMakeupEngine#RemoveMakeup| Remove} the created effect destroying the record inside releasing memory.</li>
* </ol>
*
* @async
* @throws {Error}
* @param {ProductUsagePattern} productUsagePattern The product usage pattern to be rendered
* @param {BoundingBox} [boundingBox=[0.0, 0.0, 1.0, 1.0]] Bounding box of the rendered Product usage pattern defined as [lowerLeftX, lowerLeftY, upperRightX, upperRightY] in the range [0.0, 1.0]
* @returns {Promise<void>} - Returns a promise witch upon completion adds a record to {@link CmsMakeupEngine}
*/
async CreateMakeup(
productUsagePattern,
boundingBox = [0.0, 0.0, 1.0, 1.0],
) {
if (this.effectsRecord.has(productUsagePattern.id))
throw new Error("Product usage pattern with id already exists");
const effects = [];
const templates = productUsagePattern.usagePatternAreas.map((upa) =>
this.#UsagePatternAreaToTemplates(upa),
);
for (const { feature, finish } of templates) {
const effect = await this.globalEngine.CreateMakeup(
feature,
finish,
boundingBox,
);
effects.push(effect);
}
this.effectsRecord.set(productUsagePattern.id, {
pup: productUsagePattern,
effects,
});
}
/**
* Destroys given {@link ProductUsagePattern}
*
* @param {ProductUsagePattern} productUsagePattern
*/
RemoveMakeup(productUsagePattern) {
// It doesn't matter what we receive (as this is user's input and can be anything),
// We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
const pupId = productUsagePattern.id;
if (!this.effectsRecord.has(pupId)) return;
const record = this.effectsRecord.get(pupId);
if (!record) return; // if effects to update were not found silently do nothing - per prior implementation
const upasWithEffect = zip(
record.pup.usagePatternAreas,
record.effects,
);
if (this.IsMakeupDisplayed(record.pup)) {
this.#sendDisabled(record.pup, upasWithEffect);
}
record.effects.forEach((eff) => {
this.globalEngine.RemoveMakeup(eff);
});
this.effectsRecord.delete(pupId);
}
/**
* Displays given {@link ProductUsagePattern} on the {@link CmsMakeupEngine#GetView |View}
*
* @param {ProductUsagePattern} productUsagePattern
*/
DisplayMakeup(productUsagePattern) {
// It doesn't matter what we receive (as this is user's input and can be anything),
// We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
const pupId = productUsagePattern.id;
const record = this.effectsRecord.get(pupId);
if (!record) return; // if effects were not found silently do nothing - this is not handled and will break. Now it's silent skip per prior implementatio
const upasWithEffect = zip(
record.pup.usagePatternAreas,
record.effects,
);
// Only send if it's not already displayed
if (!this.IsMakeupDisplayed(record.pup)) {
this.#sendSimulated(record.pup, upasWithEffect);
}
record.effects.forEach((eff) => {
this.globalEngine.DisplayMakeup(eff);
});
}
/**
* Hides given {@link ProductUsagePattern} from the {@link CmsMakeupEngine#GetView|View}
* The pattern is still recorded inside the Engine and can be {@link CmsMakeupEngine#DisplayMakeup|redisplayed} again.
*
* @param {ProductUsagePattern} productUsagePattern
*/
HideMakeup(productUsagePattern) {
// It doesn't matter what we receive (as this is user's input and can be anything),
// We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
const pupId = productUsagePattern.id;
const record = this.effectsRecord.get(pupId);
if (!record) return; // if effects were not found silently do nothing - this is not handled and will break. Now it's silent skip per prior implementatio
const upasWithEffect = zip(
record.pup.usagePatternAreas,
record.effects,
);
// Only send if it is displayed
if (this.IsMakeupDisplayed(record.pup)) {
this.#sendDisabled(record.pup, upasWithEffect);
}
record.effects.forEach((eff) => {
this.globalEngine.DisplayMakeup(eff);
});
}
/**
* Update created product usage pattern to display different colors or opacities.
* This method is intended for quick changes in rendering similar to {@link MakeupEngine#UpdateMakeup| MakeupEngine.UpdateMakeup}.
*
* @async
* @param {ProductUsagePattern} productUsagePattern - Product usage pattern to be udpated
* @param {UpdateMakeupConfig} [updateMakeupConfig = { analyticsDebounceTime: 300, boundingBox: undefined }] - Additional configurationm options
* @returns {Promise<void>}
*/
async UpdateMakeup(productUsagePattern, updateMakeupConfig = {}) {
if (!this.effectsRecord.has(productUsagePattern.id)) return;
const boundingBox = updateMakeupConfig?.boundingBox;
const analyticsDebounceTime =
updateMakeupConfig?.analyticsDebounceTime ?? 300; // debounce 300ms default
const record = this.effectsRecord.get(productUsagePattern.id);
if (!record) return; // if effects to update were not found silently do nothing - per prior implementation
const effects = record.effects;
if (!effects?.length) return; // if effects to update were not found silently do nothing - per prior implementation
// Although not enforced anywhere in the code in any way, there exists a hidden pre-condition
// The update function may only update a subset of properties in the effect, eg. color.
// Any other more drastic changes, including having different number of effects, executes in undefined way.
// Be careful as that means correctness of the program is compromised.
// Conceptualy it's an error if lenghts are not same
// It is left like that as per prior implementation - continue, ignore semantic issues & hope for best "strategy"
// It's reworked to work with minimal lenght zip of 2 lists
const upasWithEffect = zip(
productUsagePattern.usagePatternAreas,
effects,
);
for (const {upa, effect} of upasWithEffect) {
// This loop will modify the effect.
// There is no recovery to revert in case of an error!
const colorElement = effect.elements.find(
(e) => e.name === "Color",
);
if (colorElement) {
colorElement.color = hexToColor(
upa.shadeColor.rendering,
upa.shadeColor.colorOpacity / 100,
);
}
// This relies on the fact that finish is allways the last element of the effect.
// Be carefull as that is an incidental behaviour instead of semantic guarantee.
const finish = effect.elements[effect.elements.length - 1];
if (finish) {
finish.color.a = upa.shadeColor.finishOpacity / 100;
}
if (boundingBox) {
effect.boundingBox = boundingBox;
}
}
const updates = effects.map((eff) =>
this.globalEngine.UpdateMakeup(eff),
);
// Only (queue) send if it is displayed
if (this.IsMakeupDisplayed(record.pup)) {
updateMakeupDebounce2(
productUsagePattern.id,
() =>
this.#sendSimulated(
record.pup,
upasWithEffect,
),
analyticsDebounceTime,
);
}
await Promise.all(updates);
}
// #endregion
}
/**
* @param {string} hex Handles "0xRRGGBB" and "0xRGB" format only. "0x" prefix is fixed,
* R, G, B are hex numbers representing the obvious colors
* @returns {Array<number>} - an RGB triplet, integers in range 0-255
*/
function hexToRgb(hex) {
// Remove the hash if present
hex = hex.replace(/^0x/, "");
// If it's a 3-character hex code, expand it to 6 characters
if (hex.length === 3) {
hex = hex
.split("")
.map(function (char) {
return char + char;
})
.join("");
}
// Convert the hex string to RGB
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Return the RGB color code
return [r, g, b];
}
/**
* @param {string} hex Handles "0xRRGGBB" and "0xRGB" format only. "0x" prefix is fixed,
* R, G, B are hex numbers representing the obvious colors
* @param {number} a Alpha value. Defaults to 1.0 (fully opaque)
* @returns {Color} - an RGB triplet, integers in range 0-255
*/
function hexToColor(hex, a = 1.0) {
const [r, g, b] = hexToRgb(hex)
// Triplet is in 0-255 value range but needs to be normalized to 0.0-1.0 for color
.map((x) => x / 255);
return new Color(r, g, b, a);
}
/**
* @typedef {Object} UpaEffectPair
* @property {UsagePatternArea} upa
* @property {Effect} effect
*/
/**
* Pairs elements from two arrays into a sequence of tuples.
* The length of the returned array is equal to the length of the shorter input array.
* Non-generic specialised version.
* @param {UsagePatternArea[]} arr1 - The first array of elements.
* @param {Effect[]} arr2 - The second array of elements.
* @returns {UpaEffectPair[]} An array of tuples containing paired elements.
*/
function zip(arr1, arr2) {
const minLength = Math.min(arr1.length, arr2.length);
return arr1.slice(0, minLength).map((val, i) => ({ upa: val, effect: arr2[i] }));
}