mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
* feat(engine): drawElementImage capture service * feat(engine): 3D projection + compositor-effect risk gate * fix(engine): gate filter drop-shadow wherever blur gates (review) detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only detected blur( in its three scan paths — a drop-shadow comp stayed on the fast path despite the gate's own correctness contract. Detect drop-shadow( in computed styles, stylesheet rules, and GSAP tween vars, pinned by a focused test that runs the real page-side closure against a DOM shim (computed / stylesheet / tween coverage + blur regression + effect-free null). Addresses miguel-heygen's blocker on #1918. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension # Conflicts: # packages/engine/src/services/screenshotService.ts * fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review) Addresses miguel-heygen's blockers on #1919: - HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to a documented diagnostic flag: it exists for upstream-Chromium repro work (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect classes; renders under it may be damaged BY DESIGN since it bypasses gates whose thresholds encode measured damage. Never production; the safety-net blank guard also stands down under it so diagnostic frames arrive unmodified. - armStaticDedup is now idempotent: the drawElement init path arms dedup before canvas injection, then initializeSession called it again — the second run overwrote the armed state with skipReason="capture_mode" (captureMode is "drawelement" by then), producing contradictory telemetry (armed frames + a skip reason), and re-ran the verification seeks on the fallback path. It now no-ops once staticFrames or a skip decision exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(producer): fast-capture render stages + remote bg-image localizer * feat(lint,player): fast-capture lint rule + player media sync --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import {
|
|
PLAY_ICON,
|
|
PAUSE_ICON,
|
|
VOLUME_HIGH_ICON,
|
|
VOLUME_LOW_ICON,
|
|
VOLUME_MUTED_ICON,
|
|
} from "./styles.js";
|
|
|
|
export interface ControlsCallbacks {
|
|
onPlay: () => void;
|
|
onPause: () => void;
|
|
onSeek: (fraction: number) => void;
|
|
/** Scrub drag started (mousedown/touchstart on the scrubber). */
|
|
onScrubStart?: () => void;
|
|
/** Scrub drag ended (mouseup/touchend). */
|
|
onScrubEnd?: () => void;
|
|
onSpeedChange: (speed: number) => void;
|
|
onMuteToggle: () => void;
|
|
onVolumeChange: (volume: number) => void;
|
|
}
|
|
|
|
/** Default logarithmic speed presets — each step roughly doubles/halves. */
|
|
export const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
|
|
|
export interface ControlsOptions {
|
|
/** Speed presets shown in the menu. Defaults to SPEED_PRESETS. */
|
|
speedPresets?: readonly number[];
|
|
/**
|
|
* When true, the volume controls (mute button + volume slider) are hidden so
|
|
* the viewer cannot change the audio state. Backs the `audio-locked`
|
|
* attribute on `<hyperframes-player>`, which enforces host-mandated silent
|
|
* playback (e.g. a HyperFrames project embedded in a chat host). Toggleable
|
|
* at runtime via the returned `setVolumeControlsHidden`.
|
|
*/
|
|
audioLocked?: boolean;
|
|
}
|
|
|
|
export function formatSpeed(speed: number): string {
|
|
return Number.isInteger(speed) ? `${speed}x` : `${speed}x`;
|
|
}
|
|
|
|
export function formatTime(seconds: number): string {
|
|
// Handle non-finite values gracefully
|
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
return "0:00";
|
|
}
|
|
const s = Math.floor(seconds);
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
export function createControls(
|
|
parent: ShadowRoot | HTMLElement,
|
|
callbacks: ControlsCallbacks,
|
|
options: ControlsOptions = {},
|
|
): {
|
|
updateTime: (current: number, duration: number) => void;
|
|
updatePlaying: (playing: boolean) => void;
|
|
updateSpeed: (speed: number) => void;
|
|
updateMuted: (muted: boolean) => void;
|
|
updateVolume: (volume: number) => void;
|
|
setVolumeControlsHidden: (hidden: boolean) => void;
|
|
show: () => void;
|
|
hide: () => void;
|
|
destroy: () => void;
|
|
} {
|
|
const presets = options.speedPresets ?? SPEED_PRESETS;
|
|
|
|
const controls = document.createElement("div");
|
|
controls.className = "hfp-controls";
|
|
// Keep overlay interactions from falling through to the host-level click toggle.
|
|
controls.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
});
|
|
|
|
const playBtn = document.createElement("button");
|
|
playBtn.className = "hfp-play-btn";
|
|
playBtn.type = "button";
|
|
// Both glyphs stay in the DOM, stacked; toggling `hfp-playing` crossfade-morphs
|
|
// between them (see styles.ts) instead of swapping innerHTML, which would kill
|
|
// the transition.
|
|
playBtn.innerHTML = `<span class="hfp-ico hfp-ico-play">${PLAY_ICON}</span><span class="hfp-ico hfp-ico-pause">${PAUSE_ICON}</span>`;
|
|
playBtn.setAttribute("aria-label", "Play");
|
|
|
|
const scrubber = document.createElement("div");
|
|
scrubber.className = "hfp-scrubber";
|
|
const progress = document.createElement("div");
|
|
progress.className = "hfp-progress";
|
|
progress.style.width = "0%";
|
|
scrubber.appendChild(progress);
|
|
|
|
const time = document.createElement("span");
|
|
time.className = "hfp-time";
|
|
time.textContent = "0:00 / 0:00";
|
|
|
|
const speedWrap = document.createElement("div");
|
|
speedWrap.className = "hfp-speed-wrap";
|
|
|
|
const speedBtn = document.createElement("button");
|
|
speedBtn.className = "hfp-speed-btn";
|
|
speedBtn.type = "button";
|
|
speedBtn.textContent = "1x";
|
|
speedBtn.setAttribute("aria-label", "Playback speed");
|
|
|
|
const speedMenu = document.createElement("div");
|
|
speedMenu.className = "hfp-speed-menu";
|
|
speedMenu.setAttribute("role", "menu");
|
|
for (const preset of presets) {
|
|
const item = document.createElement("button");
|
|
item.className = "hfp-speed-option";
|
|
item.type = "button";
|
|
item.setAttribute("role", "menuitem");
|
|
item.dataset.speed = String(preset);
|
|
item.textContent = formatSpeed(preset);
|
|
if (preset === 1) item.classList.add("hfp-active");
|
|
speedMenu.appendChild(item);
|
|
}
|
|
|
|
speedWrap.appendChild(speedMenu);
|
|
speedWrap.appendChild(speedBtn);
|
|
|
|
const volumeWrap = document.createElement("div");
|
|
volumeWrap.className = "hfp-volume-wrap";
|
|
|
|
const muteBtn = document.createElement("button");
|
|
muteBtn.className = "hfp-mute-btn";
|
|
muteBtn.type = "button";
|
|
muteBtn.innerHTML = VOLUME_HIGH_ICON;
|
|
muteBtn.setAttribute("aria-label", "Mute");
|
|
|
|
const volumeSliderWrap = document.createElement("div");
|
|
volumeSliderWrap.className = "hfp-volume-slider-wrap";
|
|
|
|
const volumeSlider = document.createElement("div");
|
|
volumeSlider.className = "hfp-volume-slider";
|
|
volumeSlider.setAttribute("role", "slider");
|
|
volumeSlider.setAttribute("aria-label", "Volume");
|
|
volumeSlider.setAttribute("aria-valuemin", "0");
|
|
volumeSlider.setAttribute("aria-valuemax", "100");
|
|
volumeSlider.setAttribute("aria-valuenow", "100");
|
|
volumeSlider.tabIndex = 0;
|
|
const volumeFill = document.createElement("div");
|
|
volumeFill.className = "hfp-volume-fill";
|
|
volumeFill.style.width = "100%";
|
|
volumeSlider.appendChild(volumeFill);
|
|
volumeSliderWrap.appendChild(volumeSlider);
|
|
|
|
volumeWrap.appendChild(volumeSliderWrap);
|
|
volumeWrap.appendChild(muteBtn);
|
|
|
|
// Audio-locked: hide the whole volume control (mute toggle + slider) so the
|
|
// viewer has no UI path to turn sound on. The player still mutes the media
|
|
// independently via the `muted` attribute; this only removes the controls.
|
|
if (options.audioLocked) volumeWrap.style.display = "none";
|
|
|
|
controls.appendChild(playBtn);
|
|
controls.appendChild(scrubber);
|
|
controls.appendChild(time);
|
|
controls.appendChild(volumeWrap);
|
|
controls.appendChild(speedWrap);
|
|
parent.appendChild(controls);
|
|
|
|
let isPlaying = false;
|
|
let isMuted = false;
|
|
let currentVolume = 1;
|
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let speedIndex = presets.indexOf(1); // start at 1x
|
|
if (speedIndex === -1) speedIndex = 0;
|
|
|
|
const getVolumeIcon = (muted: boolean, volume: number): string => {
|
|
if (muted) return VOLUME_MUTED_ICON;
|
|
if (volume === 0) return VOLUME_LOW_ICON;
|
|
if (volume < 0.5) return VOLUME_LOW_ICON;
|
|
return VOLUME_HIGH_ICON;
|
|
};
|
|
|
|
playBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (isPlaying) callbacks.onPause();
|
|
else callbacks.onPlay();
|
|
});
|
|
|
|
muteBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
callbacks.onMuteToggle();
|
|
});
|
|
|
|
let volumeScrubbing = false;
|
|
|
|
const handleVolumeAt = (clientX: number) => {
|
|
const rect = volumeSlider.getBoundingClientRect();
|
|
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
currentVolume = fraction;
|
|
volumeFill.style.width = `${fraction * 100}%`;
|
|
volumeSlider.setAttribute("aria-valuenow", String(Math.round(fraction * 100)));
|
|
if (isMuted && fraction > 0) callbacks.onMuteToggle();
|
|
muteBtn.innerHTML = getVolumeIcon(isMuted, fraction);
|
|
callbacks.onVolumeChange(fraction);
|
|
};
|
|
|
|
volumeSlider.addEventListener("mousedown", (e) => {
|
|
e.stopPropagation();
|
|
volumeScrubbing = true;
|
|
handleVolumeAt(e.clientX);
|
|
});
|
|
const onVolumeMouseMove = (e: MouseEvent) => {
|
|
if (volumeScrubbing) handleVolumeAt(e.clientX);
|
|
};
|
|
const onVolumeMouseUp = () => {
|
|
volumeScrubbing = false;
|
|
};
|
|
document.addEventListener("mousemove", onVolumeMouseMove);
|
|
document.addEventListener("mouseup", onVolumeMouseUp);
|
|
|
|
volumeSlider.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
volumeScrubbing = true;
|
|
const touch = e.touches[0];
|
|
if (touch) handleVolumeAt(touch.clientX);
|
|
},
|
|
{ passive: true },
|
|
);
|
|
const onVolumeTouchMove = (e: TouchEvent) => {
|
|
if (volumeScrubbing) {
|
|
const touch = e.touches[0];
|
|
if (touch) handleVolumeAt(touch.clientX);
|
|
}
|
|
};
|
|
const onVolumeTouchEnd = () => {
|
|
volumeScrubbing = false;
|
|
};
|
|
document.addEventListener("touchmove", onVolumeTouchMove, { passive: true });
|
|
document.addEventListener("touchend", onVolumeTouchEnd);
|
|
|
|
const VOLUME_STEP = 0.05;
|
|
volumeSlider.addEventListener("keydown", (e) => {
|
|
let newVol = currentVolume;
|
|
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
|
newVol = Math.min(1, currentVolume + VOLUME_STEP);
|
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
|
newVol = Math.max(0, currentVolume - VOLUME_STEP);
|
|
} else {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
currentVolume = newVol;
|
|
volumeFill.style.width = `${newVol * 100}%`;
|
|
volumeSlider.setAttribute("aria-valuenow", String(Math.round(newVol * 100)));
|
|
if (isMuted && newVol > 0) callbacks.onMuteToggle();
|
|
muteBtn.innerHTML = getVolumeIcon(isMuted, newVol);
|
|
callbacks.onVolumeChange(newVol);
|
|
});
|
|
|
|
const setActiveOption = (speed: number) => {
|
|
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
|
|
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
|
|
}
|
|
};
|
|
|
|
speedBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = speedMenu.classList.toggle("hfp-open");
|
|
speedBtn.setAttribute("aria-expanded", String(isOpen));
|
|
});
|
|
|
|
speedMenu.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const target = (e.target as HTMLElement).closest(".hfp-speed-option") as HTMLElement | null;
|
|
if (!target) return;
|
|
const newSpeed = parseFloat(target.dataset.speed!);
|
|
speedIndex = presets.indexOf(newSpeed);
|
|
speedBtn.textContent = formatSpeed(newSpeed);
|
|
setActiveOption(newSpeed);
|
|
speedMenu.classList.remove("hfp-open");
|
|
speedBtn.setAttribute("aria-expanded", "false");
|
|
callbacks.onSpeedChange(newSpeed);
|
|
});
|
|
|
|
// Close menu when clicking outside
|
|
const onDocClick = () => {
|
|
speedMenu.classList.remove("hfp-open");
|
|
speedBtn.setAttribute("aria-expanded", "false");
|
|
};
|
|
document.addEventListener("click", onDocClick);
|
|
|
|
const handleScrubAt = (clientX: number) => {
|
|
const rect = scrubber.getBoundingClientRect();
|
|
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
callbacks.onSeek(fraction);
|
|
};
|
|
|
|
let scrubbing = false;
|
|
|
|
scrubber.addEventListener("mousedown", (e) => {
|
|
e.stopPropagation();
|
|
scrubbing = true;
|
|
callbacks.onScrubStart?.();
|
|
handleScrubAt(e.clientX);
|
|
});
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
if (scrubbing) handleScrubAt(e.clientX);
|
|
};
|
|
const onMouseUp = () => {
|
|
if (scrubbing) {
|
|
scrubbing = false;
|
|
callbacks.onScrubEnd?.();
|
|
}
|
|
};
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
|
|
scrubber.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
scrubbing = true;
|
|
callbacks.onScrubStart?.();
|
|
const touch = e.touches[0];
|
|
if (touch) handleScrubAt(touch.clientX);
|
|
},
|
|
{ passive: true },
|
|
);
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (scrubbing) {
|
|
const touch = e.touches[0];
|
|
if (touch) handleScrubAt(touch.clientX);
|
|
}
|
|
};
|
|
const onTouchEnd = () => {
|
|
if (scrubbing) {
|
|
scrubbing = false;
|
|
callbacks.onScrubEnd?.();
|
|
}
|
|
};
|
|
document.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
document.addEventListener("touchend", onTouchEnd);
|
|
|
|
const startHideTimer = () => {
|
|
if (hideTimeout) clearTimeout(hideTimeout);
|
|
hideTimeout = setTimeout(() => {
|
|
if (isPlaying) controls.classList.add("hfp-hidden");
|
|
}, 3000);
|
|
};
|
|
|
|
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
|
|
const onHostMouseMove = () => {
|
|
controls.classList.remove("hfp-hidden");
|
|
startHideTimer();
|
|
};
|
|
const onHostMouseLeave = () => {
|
|
if (isPlaying) controls.classList.add("hfp-hidden");
|
|
};
|
|
host.addEventListener("mousemove", onHostMouseMove);
|
|
host.addEventListener("mouseleave", onHostMouseLeave);
|
|
|
|
return {
|
|
updateTime(current: number, duration: number) {
|
|
// Defensive: source should already clamp, but guard here so the UI never overflows.
|
|
const clampedCurrent = duration > 0 ? Math.min(current, duration) : current;
|
|
const pct = duration > 0 ? (clampedCurrent / duration) * 100 : 0;
|
|
progress.style.width = `${pct}%`;
|
|
time.textContent = `${formatTime(clampedCurrent)} / ${formatTime(duration)}`;
|
|
},
|
|
updatePlaying(playing: boolean) {
|
|
isPlaying = playing;
|
|
playBtn.classList.toggle("hfp-playing", playing);
|
|
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
|
|
if (playing) startHideTimer();
|
|
else controls.classList.remove("hfp-hidden");
|
|
},
|
|
updateSpeed(speed: number) {
|
|
const idx = presets.indexOf(speed);
|
|
if (idx !== -1) speedIndex = idx;
|
|
speedBtn.textContent = formatSpeed(speed);
|
|
setActiveOption(speed);
|
|
},
|
|
updateMuted(muted: boolean) {
|
|
isMuted = muted;
|
|
muteBtn.innerHTML = getVolumeIcon(muted, currentVolume);
|
|
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
|
|
},
|
|
updateVolume(volume: number) {
|
|
currentVolume = volume;
|
|
volumeFill.style.width = `${volume * 100}%`;
|
|
volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100)));
|
|
muteBtn.innerHTML = getVolumeIcon(isMuted, volume);
|
|
},
|
|
setVolumeControlsHidden(hidden: boolean) {
|
|
volumeWrap.style.display = hidden ? "none" : "";
|
|
},
|
|
show() {
|
|
controls.style.display = "";
|
|
},
|
|
hide() {
|
|
controls.style.display = "none";
|
|
},
|
|
destroy() {
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
document.removeEventListener("touchmove", onTouchMove);
|
|
document.removeEventListener("touchend", onTouchEnd);
|
|
document.removeEventListener("mousemove", onVolumeMouseMove);
|
|
document.removeEventListener("mouseup", onVolumeMouseUp);
|
|
document.removeEventListener("touchmove", onVolumeTouchMove);
|
|
document.removeEventListener("touchend", onVolumeTouchEnd);
|
|
document.removeEventListener("click", onDocClick);
|
|
host.removeEventListener("mousemove", onHostMouseMove);
|
|
host.removeEventListener("mouseleave", onHostMouseLeave);
|
|
if (hideTimeout) clearTimeout(hideTimeout);
|
|
controls.remove();
|
|
},
|
|
};
|
|
}
|