mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
* refactor: extract @hyperframes/studio-server package from core Moves all studio-api routes, helpers, and Hono server wiring from packages/core/src/studio-api/ into a new standalone packages/studio-server package (@hyperframes/studio-server). Core keeps thin re-export stubs at @hyperframes/core/studio-api and the subpath helpers (screenshot-clip, draft-markers, etc.) for backward compatibility. Consumer imports (cli studioServer, vite adapter/config, producer htmlCompiler, studio manualEditsTypes) are updated to import from @hyperframes/studio-server directly. Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was in compiler/rewriteSubCompPaths.ts but not re-exported), required by @hyperframes/studio-server/helpers/subComposition. Removes postcss-selector-parser from @hyperframes/core dependencies (moved to @hyperframes/studio-server which owns the routes that used it). Depends on @hyperframes/parsers (PR #1755). * fix(ci): add parsers+studio-server to Dockerfile and build before preview tests * fix(ci): build @hyperframes/studio-server before Test and studio load smoke Studio's vite.config.ts imports @hyperframes/studio-server, which resolves via its "node" export condition to built dist. The Test and studio-load-smoke jobs only built parsers + core, so esbuild's config load failed to resolve the package entry. Build studio-server too. * fix(studio): repoint sdkCutoverParity test import to studio-server sourceMutation moved from core's studio-api to @hyperframes/studio-server; the test still imported the deleted core path. This was masked while studio's vite.config failed to load (couldn't resolve studio-server); now that the config loads, the test runs and the stale import surfaced.
736 lines
26 KiB
TypeScript
736 lines
26 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
export interface StudioManualEditsRenderScriptOptions {
|
|
activeCompositionPath?: string | null;
|
|
}
|
|
|
|
export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
|
|
|
|
export function createStudioManualEditsRenderBodyScript(
|
|
manifestContent: string,
|
|
options: StudioManualEditsRenderScriptOptions = {},
|
|
): string | null {
|
|
if (!manifestContent.trim()) return null;
|
|
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
|
|
}
|
|
|
|
/**
|
|
* Returns a self-contained IIFE string that re-applies studio position edits
|
|
* (translate, rotate) after every GSAP seek by querying data attributes baked
|
|
* into the HTML. Works without a JSON manifest — positions are already inlined
|
|
* as CSS custom properties on the elements.
|
|
*/
|
|
export function createStudioPositionSeekReapplyScript(): string {
|
|
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
|
|
}
|
|
|
|
function studioPositionSeekReapplyRuntime(): void {
|
|
const OFFSET_X_PROP = "--hf-studio-offset-x";
|
|
const OFFSET_Y_PROP = "--hf-studio-offset-y";
|
|
const WIDTH_PROP = "--hf-studio-width";
|
|
const HEIGHT_PROP = "--hf-studio-height";
|
|
const ROTATION_PROP = "--hf-studio-rotation";
|
|
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
|
|
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
|
|
const ROTATION_ATTR = "data-hf-studio-rotation";
|
|
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
|
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
|
const MOTION_ATTR = "data-hf-studio-motion";
|
|
const MOTION_TL_KEY = "studio-motion";
|
|
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
|
|
|
|
if (
|
|
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
|
|
!document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') &&
|
|
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
|
|
!document.querySelector("[" + MOTION_ATTR + "]")
|
|
)
|
|
return;
|
|
|
|
const splitTopLevelWhitespace = (value: string): string[] => {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
for (const char of value.trim()) {
|
|
if (char === "(") depth += 1;
|
|
if (char === ")") depth = Math.max(0, depth - 1);
|
|
if (/\s/.test(char) && depth === 0) {
|
|
if (current) parts.push(current);
|
|
current = "";
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
if (current) parts.push(current);
|
|
return parts;
|
|
};
|
|
|
|
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
|
|
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
|
|
if (!original || original === "none") return x + " " + y;
|
|
const parts = splitTopLevelWhitespace(original);
|
|
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
|
|
if (parts.length >= 2) {
|
|
const z = parts.length >= 3 ? " " + parts[2] : "";
|
|
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
|
|
}
|
|
return x + " " + y;
|
|
};
|
|
|
|
const isSimpleRotateAngle = (value: string): boolean =>
|
|
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
|
|
|
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
|
|
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
|
|
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
|
|
return "calc(" + original + " + " + rotationValue + ")";
|
|
};
|
|
|
|
let lastSeekTime = 0;
|
|
let cachedMotionKey = "";
|
|
|
|
const finiteNum = (v: unknown): number | null =>
|
|
typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
|
|
const computeMotionKey = (motionEls: NodeListOf<Element>): string => {
|
|
let key = "";
|
|
for (let i = 0; i < motionEls.length; i++) {
|
|
const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR);
|
|
if (json) key += (key ? "\n" : "") + json;
|
|
}
|
|
return key;
|
|
};
|
|
|
|
const reapplyMotionTimeline = (): void => {
|
|
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
|
|
if (motionEls.length === 0) {
|
|
cachedMotionKey = "";
|
|
return;
|
|
}
|
|
const win = window as Window & {
|
|
gsap?: {
|
|
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
|
|
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
|
|
registerPlugin?: (plugin: unknown) => void;
|
|
};
|
|
CustomEase?: { create?: (id: string, data: string) => void };
|
|
__timelines?: Record<string, Record<string, unknown>>;
|
|
};
|
|
const gsap = win.gsap;
|
|
if (!gsap || typeof gsap.timeline !== "function") return;
|
|
win.__timelines = win.__timelines || {};
|
|
|
|
// Cache the timeline keyed by the concatenated motion JSON strings.
|
|
// On each seek, if the key hasn't changed, just seek the existing timeline
|
|
// instead of rebuilding it (avoids kill+recreate on every frame).
|
|
const motionKey = computeMotionKey(motionEls);
|
|
const existing = win.__timelines[MOTION_TL_KEY];
|
|
if (
|
|
motionKey &&
|
|
motionKey === cachedMotionKey &&
|
|
existing &&
|
|
typeof existing.totalTime === "function"
|
|
) {
|
|
(existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
|
|
return;
|
|
}
|
|
|
|
if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
|
|
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
|
|
const fromTo = tl.fromTo as (
|
|
el: HTMLElement,
|
|
from: Record<string, unknown>,
|
|
to: Record<string, unknown>,
|
|
pos: number,
|
|
) => void;
|
|
if (typeof fromTo !== "function") return;
|
|
let applied = 0;
|
|
for (let i = 0; i < motionEls.length; i++) {
|
|
const el = motionEls[i] as HTMLElement;
|
|
if (!(el instanceof HTMLElement)) continue;
|
|
const json = el.getAttribute(MOTION_ATTR);
|
|
if (!json) continue;
|
|
try {
|
|
const m = JSON.parse(json) as Record<string, unknown>;
|
|
const start = finiteNum(m.start);
|
|
const duration = finiteNum(m.duration);
|
|
if (start == null || duration == null || duration <= 0) continue;
|
|
const ease = typeof m.ease === "string" ? m.ease : "none";
|
|
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
|
|
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
|
|
let resolvedEase = ease;
|
|
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
|
|
try {
|
|
gsap.registerPlugin?.(win.CustomEase);
|
|
win.CustomEase.create(customEase.id, customEase.data);
|
|
resolvedEase = customEase.id;
|
|
} catch {
|
|
/* use default ease */
|
|
}
|
|
}
|
|
fromTo.call(
|
|
tl,
|
|
el,
|
|
{ ...from },
|
|
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
|
|
start,
|
|
);
|
|
applied += 1;
|
|
} catch {
|
|
/* malformed JSON — skip */
|
|
}
|
|
}
|
|
if (applied === 0) {
|
|
cachedMotionKey = "";
|
|
if (typeof (tl as { kill?: () => void }).kill === "function")
|
|
(tl as { kill: () => void }).kill();
|
|
return;
|
|
}
|
|
cachedMotionKey = motionKey;
|
|
win.__timelines[MOTION_TL_KEY] = tl;
|
|
if (typeof tl.pause === "function") (tl.pause as () => void)();
|
|
if (typeof tl.totalTime === "function")
|
|
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
|
|
};
|
|
|
|
const stripGsapTranslateFromTransform = (el: HTMLElement): void => {
|
|
const transform = el.style.getPropertyValue("transform");
|
|
if (!transform || transform === "none") return;
|
|
const win = el.ownerDocument.defaultView as (Window & typeof globalThis) | null;
|
|
const MatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
|
|
if (!MatrixCtor) return;
|
|
try {
|
|
const m = new MatrixCtor(transform);
|
|
if (m.m41 === 0 && m.m42 === 0) return;
|
|
m.m41 = 0;
|
|
m.m42 = 0;
|
|
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
|
|
el.style.removeProperty("transform");
|
|
} else {
|
|
el.style.setProperty("transform", m.toString());
|
|
}
|
|
} catch {
|
|
/* non-parseable transform — leave as-is */
|
|
}
|
|
};
|
|
|
|
const reapplyAll = (): void => {
|
|
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
|
for (let i = 0; i < offsetEls.length; i++) {
|
|
const el = offsetEls[i] as HTMLElement;
|
|
if (!(el instanceof HTMLElement)) continue;
|
|
const x = el.style.getPropertyValue(OFFSET_X_PROP);
|
|
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
|
|
if (x || y) {
|
|
el.style.setProperty(
|
|
"translate",
|
|
composeTranslate(
|
|
el,
|
|
"var(" + OFFSET_X_PROP + ", 0px)",
|
|
"var(" + OFFSET_Y_PROP + ", 0px)",
|
|
),
|
|
);
|
|
stripGsapTranslateFromTransform(el);
|
|
}
|
|
}
|
|
const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]');
|
|
for (let i = 0; i < boxSizeEls.length; i++) {
|
|
const el = boxSizeEls[i] as HTMLElement;
|
|
if (!(el instanceof HTMLElement)) continue;
|
|
const w = el.style.getPropertyValue(WIDTH_PROP);
|
|
const h = el.style.getPropertyValue(HEIGHT_PROP);
|
|
if (w) el.style.setProperty("width", w);
|
|
if (h) el.style.setProperty("height", h);
|
|
}
|
|
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
|
|
for (let i = 0; i < rotEls.length; i++) {
|
|
const el = rotEls[i] as HTMLElement;
|
|
if (!(el instanceof HTMLElement)) continue;
|
|
const rot = el.style.getPropertyValue(ROTATION_PROP);
|
|
if (rot) {
|
|
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
|
|
stripGsapTranslateFromTransform(el);
|
|
}
|
|
}
|
|
reapplyMotionTimeline();
|
|
};
|
|
|
|
const runtimeWindow = window as Window & {
|
|
__hf?: Record<string, unknown>;
|
|
__player?: Record<string, unknown>;
|
|
};
|
|
|
|
const isWrapped = (fn: (time: number) => unknown): boolean =>
|
|
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_PROP]);
|
|
|
|
const markWrapped = (fn: (time: number) => unknown): void => {
|
|
try {
|
|
Object.defineProperty(fn, WRAPPED_PROP, {
|
|
configurable: false,
|
|
enumerable: false,
|
|
value: true,
|
|
});
|
|
} catch {
|
|
try {
|
|
(fn as unknown as Record<string, unknown>)[WRAPPED_PROP] = true;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
};
|
|
|
|
const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => {
|
|
const fn = get();
|
|
if (typeof fn !== "function") return false;
|
|
const seek = fn as (time: number) => unknown;
|
|
if (isWrapped(seek)) {
|
|
reapplyAll();
|
|
return true;
|
|
}
|
|
const wrapped = function (this: unknown, time: number): unknown {
|
|
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
|
|
const result = seek.call(this, time);
|
|
reapplyAll();
|
|
return result;
|
|
};
|
|
markWrapped(wrapped);
|
|
set(wrapped);
|
|
reapplyAll();
|
|
return true;
|
|
};
|
|
|
|
const wrapSeekFunctions = (): boolean => {
|
|
const a = wrapFn(
|
|
() => runtimeWindow.__hf?.["seek"],
|
|
(fn) => {
|
|
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
|
},
|
|
);
|
|
const b = wrapFn(
|
|
() => runtimeWindow.__player?.["renderSeek"],
|
|
(fn) => {
|
|
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
|
},
|
|
);
|
|
return a || b;
|
|
};
|
|
|
|
const installSeekTrap = (
|
|
obj: Record<string, unknown> | undefined,
|
|
key: string,
|
|
getter: () => unknown,
|
|
setter: (fn: (time: number) => unknown) => void,
|
|
): void => {
|
|
if (!obj) return;
|
|
try {
|
|
let current = obj[key];
|
|
Object.defineProperty(obj, key, {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get() {
|
|
return current;
|
|
},
|
|
set(value: unknown) {
|
|
current = value;
|
|
if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) {
|
|
wrapFn(getter, setter);
|
|
}
|
|
},
|
|
});
|
|
} catch {
|
|
/* non-configurable — fall back to polling */
|
|
}
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
|
|
} else {
|
|
reapplyAll();
|
|
}
|
|
|
|
wrapSeekFunctions();
|
|
installSeekTrap(
|
|
runtimeWindow.__hf,
|
|
"seek",
|
|
() => runtimeWindow.__hf?.["seek"],
|
|
(fn) => {
|
|
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
|
},
|
|
);
|
|
installSeekTrap(
|
|
runtimeWindow.__player as Record<string, unknown> | undefined,
|
|
"renderSeek",
|
|
() => runtimeWindow.__player?.["renderSeek"],
|
|
(fn) => {
|
|
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
|
},
|
|
);
|
|
let remaining = 120;
|
|
const interval = setInterval(() => {
|
|
wrapSeekFunctions();
|
|
remaining -= 1;
|
|
if (remaining <= 0) clearInterval(interval);
|
|
}, 50);
|
|
}
|
|
|
|
function studioManualEditsRenderRuntime(
|
|
manifestContent: string,
|
|
activeCompositionPath: string | null,
|
|
): void {
|
|
const OFFSET_X_PROP = "--hf-studio-offset-x";
|
|
const OFFSET_Y_PROP = "--hf-studio-offset-y";
|
|
const WIDTH_PROP = "--hf-studio-width";
|
|
const HEIGHT_PROP = "--hf-studio-height";
|
|
const ROTATION_PROP = "--hf-studio-rotation";
|
|
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
|
|
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
|
|
const ROTATION_ATTR = "data-hf-studio-rotation";
|
|
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
|
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
|
const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped";
|
|
const ROTATION_TRANSFORM_ORIGIN = "center center";
|
|
|
|
const finiteNumber = (value: unknown): number | null =>
|
|
typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
|
|
const objectRecord = (value: unknown): Record<string, unknown> | null =>
|
|
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
|
|
const runtimeWindow = window as Window & {
|
|
__hf?: { seek?: (time: number) => unknown };
|
|
__hfStudioManualEditsApply?: () => number;
|
|
__player?: { renderSeek?: (time: number) => unknown };
|
|
};
|
|
|
|
const parsedManifest = (() => {
|
|
try {
|
|
return objectRecord(JSON.parse(manifestContent));
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : [];
|
|
if (manifestEdits.length === 0) return;
|
|
|
|
const sourceFileForElement = (element: HTMLElement): string => {
|
|
let current: HTMLElement | null = element;
|
|
while (current) {
|
|
const sourceFile =
|
|
current.getAttribute("data-composition-file") ??
|
|
current.getAttribute("data-composition-src");
|
|
if (sourceFile) return sourceFile;
|
|
current = current.parentElement;
|
|
}
|
|
return activeCompositionPath ?? "index.html";
|
|
};
|
|
|
|
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
|
|
sourceFileForElement(element) === sourceFile;
|
|
|
|
const styleUsesStudioOffset = (value: string): boolean =>
|
|
value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP);
|
|
|
|
const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP);
|
|
|
|
const splitTopLevelWhitespace = (value: string): string[] => {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let current = "";
|
|
for (const char of value.trim()) {
|
|
if (char === "(") depth += 1;
|
|
if (char === ")") depth = Math.max(0, depth - 1);
|
|
if (/\s/.test(char) && depth === 0) {
|
|
if (current) parts.push(current);
|
|
current = "";
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
if (current) parts.push(current);
|
|
return parts;
|
|
};
|
|
|
|
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
|
|
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
|
|
if (!original || original === "none") return `${x} ${y}`;
|
|
|
|
const parts = splitTopLevelWhitespace(original);
|
|
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
|
|
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
|
|
if (parts.length === 3) {
|
|
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
|
|
}
|
|
return `${x} ${y}`;
|
|
};
|
|
|
|
const readStyleOrComputed = (element: HTMLElement, property: string): string => {
|
|
try {
|
|
return (
|
|
element.style.getPropertyValue(property) ||
|
|
getComputedStyle(element).getPropertyValue(property)
|
|
);
|
|
} catch {
|
|
return element.style.getPropertyValue(property);
|
|
}
|
|
};
|
|
|
|
const readTransformLonghandBase = (
|
|
element: HTMLElement,
|
|
property: "translate" | "rotate",
|
|
): string => {
|
|
const value = readStyleOrComputed(element, property).trim();
|
|
return value === "none" ? "" : value;
|
|
};
|
|
|
|
const preparePathOffsetBase = (element: HTMLElement): void => {
|
|
const currentTranslate = readTransformLonghandBase(element, "translate");
|
|
const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR);
|
|
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
|
|
if (!hasMarker) {
|
|
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : "");
|
|
} else if (wasResetByAnimation) {
|
|
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate);
|
|
}
|
|
};
|
|
|
|
const prepareRotationBase = (element: HTMLElement): void => {
|
|
const currentRotate = readTransformLonghandBase(element, "rotate");
|
|
const hasMarker = element.hasAttribute(ROTATION_ATTR);
|
|
const wasResetByAnimation = !styleUsesStudioRotation(currentRotate);
|
|
if (!hasMarker) {
|
|
element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
|
|
} else if (wasResetByAnimation) {
|
|
element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate);
|
|
}
|
|
};
|
|
|
|
const querySelectorCandidates = (selector: string): HTMLElement[] => {
|
|
const isCandidate = (element: Element): element is HTMLElement =>
|
|
element instanceof HTMLElement;
|
|
|
|
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
|
|
if (className) {
|
|
return Array.from(document.getElementsByTagName("*")).filter(
|
|
(element): element is HTMLElement =>
|
|
isCandidate(element) && element.classList.contains(className),
|
|
);
|
|
}
|
|
|
|
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
|
|
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
|
|
}
|
|
|
|
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
|
|
};
|
|
|
|
const resolveTarget = (edit: Record<string, unknown>): HTMLElement | null => {
|
|
const targetRecord = objectRecord(edit.target);
|
|
if (!targetRecord) return null;
|
|
|
|
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
|
if (!sourceFile) return null;
|
|
|
|
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
|
|
if (id) {
|
|
const byId = document.getElementById(id);
|
|
if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId;
|
|
|
|
const matchesById = [
|
|
document.documentElement,
|
|
...Array.from(document.getElementsByTagName("*")),
|
|
].filter(
|
|
(element): element is HTMLElement =>
|
|
element instanceof HTMLElement &&
|
|
element.id === id &&
|
|
elementMatchesSourceFile(element, sourceFile),
|
|
);
|
|
if (matchesById[0]) return matchesById[0];
|
|
}
|
|
|
|
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
|
|
if (!selector) return null;
|
|
|
|
try {
|
|
const matches = querySelectorCandidates(selector).filter((element) =>
|
|
elementMatchesSourceFile(element, sourceFile),
|
|
);
|
|
const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0;
|
|
return matches[Math.max(0, Math.floor(selectorIndex))] ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10;
|
|
|
|
const isSimpleRotateAngle = (value: string): boolean =>
|
|
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
|
|
|
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
|
|
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
|
|
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
|
|
return rotationValue;
|
|
}
|
|
return `calc(${original} + ${rotationValue})`;
|
|
};
|
|
|
|
const applyPathOffset = (element: HTMLElement, edit: Record<string, unknown>): void => {
|
|
const x = finiteNumber(edit.x);
|
|
const y = finiteNumber(edit.y);
|
|
if (x == null || y == null) return;
|
|
preparePathOffsetBase(element);
|
|
element.setAttribute(PATH_OFFSET_ATTR, "true");
|
|
element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`);
|
|
element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`);
|
|
element.style.setProperty(
|
|
"translate",
|
|
composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`),
|
|
);
|
|
};
|
|
|
|
const readParentFlexBasisPixels = (
|
|
element: HTMLElement,
|
|
size: { width: number; height: number },
|
|
): number | null => {
|
|
const parent = element.parentElement;
|
|
if (!parent) return null;
|
|
const styles = getComputedStyle(parent);
|
|
if (styles.display !== "flex" && styles.display !== "inline-flex") return null;
|
|
return Math.round(
|
|
Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width),
|
|
);
|
|
};
|
|
|
|
const applyBoxSize = (element: HTMLElement, edit: Record<string, unknown>): void => {
|
|
const width = finiteNumber(edit.width);
|
|
const height = finiteNumber(edit.height);
|
|
if (width == null || height == null || width <= 0 || height <= 0) return;
|
|
|
|
const rounded = {
|
|
width: Math.round(Math.max(1, width)),
|
|
height: Math.round(Math.max(1, height)),
|
|
};
|
|
element.setAttribute(BOX_SIZE_ATTR, "true");
|
|
element.style.setProperty(WIDTH_PROP, `${rounded.width}px`);
|
|
element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`);
|
|
element.style.setProperty("box-sizing", "border-box");
|
|
element.style.setProperty("width", `${rounded.width}px`);
|
|
element.style.setProperty("height", `${rounded.height}px`);
|
|
element.style.setProperty("min-width", "0px");
|
|
element.style.setProperty("min-height", "0px");
|
|
element.style.setProperty("max-width", "none");
|
|
element.style.setProperty("max-height", "none");
|
|
|
|
const flexBasis = readParentFlexBasisPixels(element, rounded);
|
|
if (flexBasis != null) {
|
|
element.style.setProperty("flex-basis", `${flexBasis}px`);
|
|
element.style.setProperty("flex-grow", "0");
|
|
element.style.setProperty("flex-shrink", "0");
|
|
}
|
|
if (getComputedStyle(element).display === "inline") {
|
|
element.style.setProperty("display", "inline-block");
|
|
}
|
|
};
|
|
|
|
const applyRotation = (element: HTMLElement, edit: Record<string, unknown>): void => {
|
|
const angle = finiteNumber(edit.angle);
|
|
if (angle == null) return;
|
|
prepareRotationBase(element);
|
|
element.setAttribute(ROTATION_ATTR, "true");
|
|
element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`);
|
|
element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN);
|
|
element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`));
|
|
};
|
|
|
|
const applyManifest = (): number => {
|
|
let applied = 0;
|
|
for (const edit of manifestEdits) {
|
|
const editRecord = objectRecord(edit);
|
|
if (!editRecord) continue;
|
|
const element = resolveTarget(editRecord);
|
|
if (!element) continue;
|
|
if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord);
|
|
if (editRecord.kind === "box-size") applyBoxSize(element, editRecord);
|
|
if (editRecord.kind === "rotation") applyRotation(element, editRecord);
|
|
applied += 1;
|
|
}
|
|
return applied;
|
|
};
|
|
runtimeWindow.__hfStudioManualEditsApply = applyManifest;
|
|
|
|
const markWrapped = (fn: (time: number) => unknown): void => {
|
|
try {
|
|
Object.defineProperty(fn, WRAPPED_SEEK_PROP, {
|
|
configurable: false,
|
|
enumerable: false,
|
|
value: true,
|
|
});
|
|
} catch {
|
|
try {
|
|
(fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP] = true;
|
|
} catch {
|
|
// Ignore non-extensible functions.
|
|
}
|
|
}
|
|
};
|
|
|
|
const isWrapped = (fn: (time: number) => unknown): boolean =>
|
|
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP]);
|
|
|
|
const wrapFunction = (
|
|
get: () => ((time: number) => unknown) | undefined,
|
|
set: (fn: (time: number) => unknown) => void,
|
|
): boolean => {
|
|
const fn = get();
|
|
if (!fn) return false;
|
|
const seek = fn as (time: number) => unknown;
|
|
if (isWrapped(seek)) {
|
|
applyManifest();
|
|
return true;
|
|
}
|
|
|
|
const wrappedSeek = function (this: unknown, time: number): unknown {
|
|
const result = seek.call(this, time);
|
|
applyManifest();
|
|
return result;
|
|
};
|
|
markWrapped(wrappedSeek);
|
|
set(wrappedSeek);
|
|
applyManifest();
|
|
return true;
|
|
};
|
|
|
|
const wrapSeekFunctions = (): boolean => {
|
|
const wrappedHfSeek = wrapFunction(
|
|
() => runtimeWindow.__hf?.seek,
|
|
(fn) => {
|
|
if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn;
|
|
},
|
|
);
|
|
const wrappedPlayerRenderSeek = wrapFunction(
|
|
() => runtimeWindow.__player?.renderSeek,
|
|
(fn) => {
|
|
if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn;
|
|
},
|
|
);
|
|
return wrappedHfSeek || wrappedPlayerRenderSeek;
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true });
|
|
} else {
|
|
applyManifest();
|
|
}
|
|
|
|
wrapSeekFunctions();
|
|
let remainingSeekWrapAttempts = 120;
|
|
const seekWrapInterval = setInterval(() => {
|
|
wrapSeekFunctions();
|
|
remainingSeekWrapAttempts -= 1;
|
|
if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval);
|
|
}, 50);
|
|
}
|