import Screen from "./Utility/Screen";
import Globals from "./Globals";
import { SdkAnalytics } from "./Analytics";
import { updateMakeupDebounce2 } from "./Debounce";
import { GlobalEngine } from "./GlobalEngine";
import { exists } from "./Utility/exists";
/** @typedef {import("./FeatureTemplate").FeatureTemplate} FeatureTemplate */
/** @typedef {import("./FeatureTemplate").FinishTemplate} FinishTemplate */
/** @typedef {import("./FeatureTemplate").BoundingBox} BoundingBox */
/** @typedef {import("./FeatureTemplate").Effect} Effect */
/** @typedef {import("./FeatureTemplate").Color} Color */
/** @typedef {import("./Analytics").EffectDataPayload} EffectDataPayload */
/**
* This class is part of the SDK's core functionality.
* <br/>
* It primarily serves two key functions:
* <br/>
* Manipulating the current output of the view throught creating, displaying, updating, hiding, and removing makeup effects.
* <br/>
* Exposure of FeatureTemplateRepository and FinishTemplateRepository deserialized from Feature.json and finishes.json files.
* <br/>
*/
export class MakeupEngine {
/**
* @param {FeatureTemplate[]} features - predefined feature templates repository
* @param {FinishTemplate[]} finishes - predefined finish templates repository
* @param {SdkAnalytics?} makeupEngineAnalytics
*/
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;
// Map is used to finish name corresponding to the effect.id because the effect structure does not provide finish name value in unambiguous way.
/** @type {Map<number, string>} */
this.finishNamesRecord = new Map();
// This does not own the effects. Effects are forwarded to user to own/mutate.
/** @type {Map<number, Effect>} */
this.activeEffects = new Map();
}
/**
* Creates makeup effect from given feature and finish templates.
* <br/>
* Additionally, this function takes an array of floats specifying a bounding box in the following way:
* - Lower left x coordinate is the first element of the array
* - Lower left y coordinate is the second element of the array
* - Upper right x coordinate is the third element of the array
* - Upper right y coordinate is the fourth element of the array
* Bounding box is set in normalized image coordinates where point (0.0, 0.0) is the lower left corner of the output image while point (1.0, 1.0) is the upper right corner of the output image.
* This effect will be visible only when its location in the output image is inside of the bounding box.
* The default bounding box is contains the whole output image.
* If the input array has less than 4 elements, the default bounding box will be used.
* <br/>
* Returns the promise which is resolved into the feature instance uniquely identifying the created effect.
* <br/>
* @async
* @param {FeatureTemplate} featureTemplate FeatureTemplate object
* @param {FinishTemplate} finishTemplate FinishTemplate object
* @param {BoundingBox} boundingBox Quuartet specifying bounding box in which this effect is visible
* @returns {Promise<Effect>}
*/
async CreateMakeup(
featureTemplate,
finishTemplate,
boundingBox = [0.0, 0.0, 1.0, 1.0],
) {
const effect = await this.globalEngine.CreateMakeup(
featureTemplate,
finishTemplate,
boundingBox,
);
this.finishNamesRecord.set(effect.id, finishTemplate?.name);
this.activeEffects.set(effect.id, effect);
return effect;
}
/**
* Displays given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
DisplayMakeup(effect) {
if (this.makeupEngineAnalytics && !this.IsMakeupDisplayed(effect)) {
this.makeupEngineAnalytics.sendEffect("EFFECT_SIMULATED", GetEffectAnalyticsData(effect, this.finishNamesRecord))
}
this.globalEngine.DisplayMakeup(effect);
}
/**JSON
* Updates color and/or area parameters of the given makeup effect
* @async
* @param {Effect} effect makeup effect
* @param {{analyticsDebounceTime: number}} updateMakeupConfig options for analytics component
* @returns {Promise<Void>}
*/
UpdateMakeup(effect, updateMakeupConfig = { analyticsDebounceTime: 300 }) {
if (
this.makeupEngineAnalytics &&
this.IsMakeupDisplayed(effect)
) {
updateMakeupDebounce2(
effect.id,
() => this.makeupEngineAnalytics?.sendEffect("EFFECT_SIMULATED", GetEffectAnalyticsData(effect, this.finishNamesRecord)),
updateMakeupConfig.analyticsDebounceTime
);
}
return this.globalEngine.UpdateMakeup(effect);
}
/**
* Check if the provided effect is currently being displayed
* @param {Effect} effect
* @returns {boolean}
*/
IsMakeupDisplayed(effect) {
return this.globalEngine.IsMakeupDisplayed(effect);
}
/**
* Hides given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
HideMakeup(effect) {
if (this.makeupEngineAnalytics && this.IsMakeupDisplayed(effect)) {
this.makeupEngineAnalytics.sendEffect("EFFECT_DISABLED", GetEffectAnalyticsData(effect, this.finishNamesRecord));
}
this.globalEngine.HideMakeup(effect);
}
/**
* Destroys given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
RemoveMakeup(effect) {
if (this.makeupEngineAnalytics && this.IsMakeupDisplayed(effect)) {
this.makeupEngineAnalytics.sendEffect("EFFECT_DISABLED", GetEffectAnalyticsData(effect, this.finishNamesRecord));
}
this.globalEngine.RemoveMakeup(effect);
this.finishNamesRecord.delete(effect.id);
this.activeEffects.delete(effect.id);
}
/**
* Destroys all created makeup effects.
*/
ClearMakeup() {
for (const effect of this.activeEffects.values()) {
this.RemoveMakeup(effect);
}
this.finishNamesRecord.clear();
this.activeEffects.clear();
}
/**
* Returns the uv area texture coordinates corresponding to position on the output view canvas that displays the makeup effect.
* @param {Effect} effect - makeup effect
* @param {number} x - pixel distance from the left output view canvas edge
* @param {number} y - pixel distance from the top output view canvas edge
*/
IntersectMakeup(effect, x, y) {
return this.globalEngine.IntersectMakeup(effect, x, y);
}
/**
* Returns a DOM element which displays the frame.
*/
GetView() {
return Screen.renderer.domElement;
}
/**
* Returns loaded feature template repository containing predefined features.
* <br/>
* Each of the predefined feature templates specify a standard renderfing procedure for each of the makeup features supported by the engine.
* @returns {FeatureTemplate[]}
*/
GetFeatures() {
return this.features;
}
/**
* Returns loaded finish template repository containing predefined finishes.
* <br/>
* Each of the predefined finish templates specify a standard renderfing procedure for each of the finishes supported by the engine.
* @returns {FinishTemplate[]}
*/
GetFinishes() {
return this.finishes;
}
/**
* Reinitializes face tracking.
* <br/>
* When using the SDK on single images, this method should be called after each image has been processed.
*/
Reset() {
Globals.tracker?.Reset();
}
/**
* 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) {
this.globalEngine.onIterationComplete(callback);
}
}
/**
* @deprecated Please use {@link MakeupEngine} class instead.
*
* This class is part of the SDK's core functionality.
* <br/>
* It primarily serves two key functions:
* <br/>
* Manipulating the current output of the view throught creating, displaying, updating, hiding, and removing makeup effects.
* <br/>
* Exposure of FeatureTemplateRepository and FinishTemplateRepository deserialized from Feature.json and finishes.json files.
* <br/>
* @param {number} width - width of the output frame
* @param {number} height - height of the output frame
* @param {Object} features - predefined feature templates repository
* @param {Object} finishes - predefined finish templates repository
*/
export class MakeUpEngine extends MakeupEngine {
/**
* @deprecated Please use CreateMakeup method.
*
* Creates makeup effect from given feature and finish templates.
* <br/>
* Additionally, this function takes an array of floats specifying a bounding box in the following way:
* - Lower left x coordinate is the first element of the array
* - Lower left y coordinate is the second element of the array
* - Upper right x coordinate is the third element of the array
* - Upper right y coordinate is the fourth element of the array
* Bounding box is set in normalized image coordinates where point (0.0, 0.0) is the lower left corner of the output image while point (1.0, 1.0) is the upper right corner of the output image.
* This effect will be visible only when its location in the output image is inside of the bounding box.
* The default bounding box is contains the whole output image.
* If the input array has less than 4 elements, the default bounding box will be used.
* <br/>
* Returns the promise which is resolved into the feature instance uniquely identifying the created effect.
* <br/>
* @param {FeatureTemplate} featureTemplate FeatureTemplate object
* @param {FinishTemplate} finishTemplate FinishTemplate object
* @param {BoundingBox} boundingBox Quuartet specifying bounding box in which this effect is visible
*/
CreateMakeUp(
featureTemplate,
finishTemplate,
boundingBox = [0.0, 0.0, 1.0, 1.0],
) {
return this.CreateMakeup(featureTemplate, finishTemplate, boundingBox);
}
/**
* @deprecated Please use DisplayMakeup method.
*
* Displays given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
DisplayMakeUp(effect) {
this.DisplayMakeup(effect);
}
/**
* @deprecated Please use UpdateMakeup method.
*
* Updates color and/or area parameters of the given makeup effect
* @async
* @param {Effect} effect makeup effect
* @returns {Promise<void>}
*/
UpdateMakeUp(effect) {
return this.UpdateMakeup(effect);
}
/**
* @deprecated Please use HideMakeup method.
*
* Hides given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
HideMakeUp(effect) {
this.HideMakeup(effect);
}
/**
* @deprecated Please use RemoveMakeup method.
*
* Destroys given makeup effect.
* <br/>
* @param {Effect} effect makeup effect
*/
RemoveMakeUp(effect) {
this.RemoveMakeup(effect);
}
/**
* @deprecated Please use ClearMakeup method.
*
* Destroys all created makeup features.
*/
ClearMakeUp() {
this.ClearMakeup();
}
}
/**
* @param {number} x
* @returns {string} hex representation of number
*/
const numToHex = (x) => {
if (typeof x !== "number") {
throw Error(`Expected a number, got ${x}`);
}
const integer = Math.round(x * 255);
if (integer < 0 || integer > 255) {
throw Error(`Expected RGB value in range [0, 255], got ${integer}`);
}
return ("0" + integer.toString(16)).slice(-2);
};
/**
* @param {Color} color
* @returns {string} `0xffffff` formated rgb string
*/
const colorToHex = (color) => {
return `0x${numToHex(color.r)}${numToHex(color.g)}${numToHex(color.b)}`;
};
/**
* @param {number} alpha
* @returns {number} in range [0, 100]
*/
const denormalizeOpacity = (alpha) => {
if (typeof alpha !== "number") {
throw new Error(`opacity ${alpha} is invalid`);
}
const integer = Math.round(alpha * 100);
if (integer < 0 || integer > 100) {
throw new Error(`Expected opacity in range [0, 100], got ${integer}`);
}
return integer;
};
/**
*
* @param {string} finishName
* @param {Effect} effect
* @returns {number} in range [0, 100]
*/
function getFinishOpacity(finishName, effect) {
const finishElementIndex = effect.elements
.map((element) => element.name)
.lastIndexOf(finishName);
if (finishElementIndex < 0) {
throw Error("Finish element not found");
}
const finishOpacityNormalized =
effect.elements[finishElementIndex]?.color?.a;
return denormalizeOpacity(finishOpacityNormalized);
}
/**
* @param {Effect} effect
* @param {Map<number, string>} finishNamesRecord
* @returns {Omit<EffectDataPayload, "session_uuid">}
*/
const GetEffectAnalyticsData = (
effect,
finishNamesRecord,
) => {
const applicationArea = effect.name;
if (!applicationArea) {
throw new Error("applicationArea is not defined in effect.name");
}
const usedColor = effect?.elements?.find(
(x) => x?.name?.toLowerCase() === "color",
)?.color; // find condition is specified according to assignment done in EffectController.Create method.
if (!usedColor) {
throw new Error("renderingHex is not defined in effect");
}
const renderingHex = colorToHex(usedColor);
const colorOpacity = denormalizeOpacity(usedColor.a);
const finishName = finishNamesRecord.get(effect.id);
const finishOpacity = finishName
? getFinishOpacity(finishName, effect)
: undefined;
return {
application_area_name: applicationArea,
rendering_hex: renderingHex,
finish_name: finishName ?? "Satin", // The case of null finish corresponds to the Satin finish.
color_opacity: colorOpacity,
finish_opacity: finishOpacity ?? 100,
bounding_box: effect.boundingBox,
effect: effect.id,
};
};