mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
refactor: extract @hyperframes/studio-server from core (#1757)
* 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.
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user