feat: add character studio and aircraft foundation
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Accessible, dependency-free profile editor mounted into a caller-owned node.
|
||||
*
|
||||
* It emits JSON profile data for a separate preview renderer. It creates no
|
||||
* canvas, Three.js object, webcam, storage, URL, or network request. User text
|
||||
* is assigned only through input `.value` and `textContent`; never `innerHTML`.
|
||||
*/
|
||||
import {
|
||||
ACCENTS,
|
||||
BODY_SHAPES,
|
||||
HAIR_COLORS,
|
||||
OUTFITS,
|
||||
SKIN_TONES,
|
||||
createDefaultLocalProfile,
|
||||
isLocalProfile,
|
||||
type HumanoidAppearanceChoices,
|
||||
type LocalProfile,
|
||||
} from "./model.ts";
|
||||
|
||||
export interface ProfileEditorOptions {
|
||||
container: HTMLElement;
|
||||
profile: LocalProfile;
|
||||
/** Used only to regenerate deterministic appearance defaults; never rendered or emitted. */
|
||||
identityId: string;
|
||||
onPreview?: (profile: LocalProfile) => void;
|
||||
onSave?: (profile: LocalProfile) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface ProfileEditorState {
|
||||
open: boolean;
|
||||
dirty: boolean;
|
||||
valid: boolean;
|
||||
/** Last saved/caller-supplied profile. */
|
||||
profile: LocalProfile;
|
||||
/** Current form values. May have an invalid display name while `valid` is false. */
|
||||
draft: LocalProfile;
|
||||
}
|
||||
|
||||
export interface ProfileEditor {
|
||||
/** Stable dialog node for host layout or integration tests. */
|
||||
root: HTMLElement;
|
||||
state(): ProfileEditorState;
|
||||
/** Replace both committed and draft data through strict profile validation. */
|
||||
update(profile: LocalProfile): ProfileEditorState;
|
||||
open(): ProfileEditorState;
|
||||
/** Hide without committing or discarding the draft. */
|
||||
close(): ProfileEditorState;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type AppearanceField = keyof HumanoidAppearanceChoices;
|
||||
|
||||
const PROFILE_EDITOR_STYLES = `
|
||||
.tera-profile-editor {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: min(720px, calc(100dvh - 32px));
|
||||
overflow: auto;
|
||||
color: var(--ink, rgba(255,255,255,.82));
|
||||
background: var(--glass-strong, rgba(9,13,18,.9));
|
||||
border: 1px solid var(--hairline, rgba(255,255,255,.13));
|
||||
border-radius: var(--r, 8px);
|
||||
box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.45));
|
||||
backdrop-filter: var(--blur, blur(14px));
|
||||
padding: var(--s5, 24px);
|
||||
font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
.tera-profile-editor__title { margin: 0 0 4px; color: white; font: 600 16px/1.3 inherit; }
|
||||
.tera-profile-editor__intro { margin: 0 0 20px; color: var(--ink-2, rgba(255,255,255,.62)); }
|
||||
.tera-profile-editor__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.tera-profile-editor__field { display: grid; gap: 6px; min-width: 0; }
|
||||
.tera-profile-editor__field--wide { grid-column: 1 / -1; }
|
||||
.tera-profile-editor__label { color: var(--ink, rgba(255,255,255,.82)); }
|
||||
.tera-profile-editor__input {
|
||||
width: 100%; min-height: 40px; padding: 8px 10px; color: white;
|
||||
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
|
||||
border-radius: var(--r-sm, 5px); font: inherit;
|
||||
}
|
||||
.tera-profile-editor__input:hover { border-color: rgba(255,255,255,.3); }
|
||||
.tera-profile-editor__input:focus-visible, .tera-profile-editor__button:focus-visible {
|
||||
outline: 2px solid var(--amber, #f2b134); outline-offset: 2px;
|
||||
}
|
||||
.tera-profile-editor__input[aria-invalid="true"] { border-color: #ff8b80; }
|
||||
.tera-profile-editor__status { min-height: 18px; margin: 12px 0 0; color: #ffb2aa; }
|
||||
.tera-profile-editor__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.tera-profile-editor__button {
|
||||
min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer;
|
||||
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
|
||||
border-radius: var(--r-sm, 5px); font: inherit;
|
||||
}
|
||||
.tera-profile-editor__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
|
||||
.tera-profile-editor__button--primary { color: #171109; background: var(--amber, #f2b134); border-color: transparent; font-weight: 700; }
|
||||
.tera-profile-editor__button--primary:hover:not(:disabled) { background: var(--amber-lit, #ffc555); }
|
||||
.tera-profile-editor__button:disabled { cursor: not-allowed; opacity: .42; }
|
||||
@media (max-width: 460px) { .tera-profile-editor__grid { grid-template-columns: 1fr; } .tera-profile-editor__field--wide { grid-column: auto; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tera-profile-editor * { scroll-behavior: auto !important; } }
|
||||
`;
|
||||
|
||||
const FIELD_OPTIONS: Readonly<Record<AppearanceField, readonly string[]>> = {
|
||||
skinTone: SKIN_TONES,
|
||||
outfit: OUTFITS,
|
||||
accent: ACCENTS,
|
||||
hair: HAIR_COLORS,
|
||||
bodyShape: BODY_SHAPES,
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<AppearanceField, string>> = {
|
||||
skinTone: "Skin tone",
|
||||
outfit: "Outfit",
|
||||
accent: "Accent",
|
||||
hair: "Hair",
|
||||
bodyShape: "Body shape",
|
||||
};
|
||||
|
||||
let editorSequence = 0;
|
||||
|
||||
function clone(profile: LocalProfile): LocalProfile {
|
||||
return { ...profile, appearance: { ...profile.appearance } };
|
||||
}
|
||||
|
||||
function presentChoice(value: string): string {
|
||||
return value.replace(/(^|-)([a-z])/g, (_match, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`);
|
||||
}
|
||||
|
||||
function validDisplayName(value: string): boolean {
|
||||
return (
|
||||
value === value.trim() &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 64 &&
|
||||
!/[\u0000-\u001f\u007f]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function sameProfile(a: LocalProfile, b: LocalProfile): boolean {
|
||||
return (
|
||||
a.version === b.version &&
|
||||
a.displayName === b.displayName &&
|
||||
a.appearance.skinTone === b.appearance.skinTone &&
|
||||
a.appearance.outfit === b.appearance.outfit &&
|
||||
a.appearance.accent === b.appearance.accent &&
|
||||
a.appearance.hair === b.appearance.hair &&
|
||||
a.appearance.bodyShape === b.appearance.bodyShape
|
||||
);
|
||||
}
|
||||
|
||||
function focusable(value: Element | null): value is HTMLElement {
|
||||
return value !== null && typeof (value as HTMLElement).focus === "function";
|
||||
}
|
||||
|
||||
export function createProfileEditor(options: ProfileEditorOptions): ProfileEditor {
|
||||
if (!options.container || typeof options.container.append !== "function") {
|
||||
throw new RangeError("profile editor: container must be an HTMLElement");
|
||||
}
|
||||
if (!isLocalProfile(options.profile)) throw new RangeError("profile editor: invalid profile");
|
||||
if (typeof options.identityId !== "string" || options.identityId.length === 0) {
|
||||
throw new RangeError("profile editor: identity id is required");
|
||||
}
|
||||
|
||||
const doc = options.container.ownerDocument;
|
||||
const number = ++editorSequence;
|
||||
const titleId = `tera-profile-title-${number}`;
|
||||
const descriptionId = `tera-profile-description-${number}`;
|
||||
const statusId = `tera-profile-status-${number}`;
|
||||
const root = doc.createElement("section");
|
||||
root.className = "tera-profile-editor";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-labelledby", titleId);
|
||||
root.setAttribute("aria-describedby", `${descriptionId} ${statusId}`);
|
||||
root.setAttribute("aria-hidden", "true");
|
||||
root.hidden = true;
|
||||
|
||||
const style = doc.createElement("style");
|
||||
style.setAttribute("data-tera-profile-editor-style", "");
|
||||
style.textContent = PROFILE_EDITOR_STYLES;
|
||||
root.append(style);
|
||||
|
||||
const title = doc.createElement("h2");
|
||||
title.id = titleId;
|
||||
title.className = "tera-profile-editor__title";
|
||||
title.textContent = "Your Tera character";
|
||||
root.append(title);
|
||||
|
||||
const intro = doc.createElement("p");
|
||||
intro.id = descriptionId;
|
||||
intro.className = "tera-profile-editor__intro";
|
||||
intro.textContent = "Choose how your procedural humanoid appears. Preview stays local to this page.";
|
||||
root.append(intro);
|
||||
|
||||
const form = doc.createElement("form");
|
||||
form.setAttribute("novalidate", "");
|
||||
const grid = doc.createElement("div");
|
||||
grid.className = "tera-profile-editor__grid";
|
||||
form.append(grid);
|
||||
|
||||
const nameField = doc.createElement("div");
|
||||
nameField.className = "tera-profile-editor__field tera-profile-editor__field--wide";
|
||||
const nameLabel = doc.createElement("label");
|
||||
const nameId = `tera-profile-name-${number}`;
|
||||
nameLabel.className = "tera-profile-editor__label";
|
||||
nameLabel.setAttribute("for", nameId);
|
||||
nameLabel.textContent = "Display name";
|
||||
const nameInput = doc.createElement("input");
|
||||
nameInput.id = nameId;
|
||||
nameInput.className = "tera-profile-editor__input";
|
||||
nameInput.setAttribute("data-field", "displayName");
|
||||
nameInput.setAttribute("aria-describedby", statusId);
|
||||
nameInput.type = "text";
|
||||
nameInput.maxLength = 64;
|
||||
nameInput.autocomplete = "name";
|
||||
nameField.append(nameLabel, nameInput);
|
||||
grid.append(nameField);
|
||||
|
||||
const selects = {} as Record<AppearanceField, HTMLSelectElement>;
|
||||
const appearanceFields = Object.keys(FIELD_OPTIONS) as AppearanceField[];
|
||||
for (const field of appearanceFields) {
|
||||
const wrapper = doc.createElement("div");
|
||||
wrapper.className = "tera-profile-editor__field";
|
||||
const label = doc.createElement("label");
|
||||
const id = `tera-profile-${field}-${number}`;
|
||||
label.className = "tera-profile-editor__label";
|
||||
label.setAttribute("for", id);
|
||||
label.textContent = FIELD_LABELS[field];
|
||||
const select = doc.createElement("select");
|
||||
select.id = id;
|
||||
select.className = "tera-profile-editor__input";
|
||||
select.setAttribute("data-field", field);
|
||||
for (const choice of FIELD_OPTIONS[field]) {
|
||||
const option = doc.createElement("option");
|
||||
option.value = choice;
|
||||
option.textContent = presentChoice(choice);
|
||||
select.append(option);
|
||||
}
|
||||
selects[field] = select;
|
||||
wrapper.append(label, select);
|
||||
grid.append(wrapper);
|
||||
}
|
||||
|
||||
const status = doc.createElement("p");
|
||||
status.id = statusId;
|
||||
status.className = "tera-profile-editor__status";
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
form.append(status);
|
||||
|
||||
const actions = doc.createElement("div");
|
||||
actions.className = "tera-profile-editor__actions";
|
||||
const resetButton = button(doc, "Reset appearance", "reset");
|
||||
const cancelButton = button(doc, "Cancel", "cancel");
|
||||
const saveButton = button(doc, "Save profile", "save", true);
|
||||
actions.append(resetButton, cancelButton, saveButton);
|
||||
form.append(actions);
|
||||
root.append(form);
|
||||
options.container.append(root);
|
||||
|
||||
const focusOrder: HTMLElement[] = [nameInput, ...appearanceFields.map((field) => selects[field]), resetButton, cancelButton, saveButton];
|
||||
let committed = clone(options.profile);
|
||||
let draft = clone(options.profile);
|
||||
let isOpen = false;
|
||||
let disposed = false;
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
|
||||
function valid(): boolean {
|
||||
return validDisplayName(draft.displayName) && isLocalProfile(draft);
|
||||
}
|
||||
|
||||
function snapshot(): ProfileEditorState {
|
||||
return {
|
||||
open: isOpen,
|
||||
dirty: !sameProfile(committed, draft),
|
||||
valid: valid(),
|
||||
profile: clone(committed),
|
||||
draft: clone(draft),
|
||||
};
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
nameInput.value = draft.displayName;
|
||||
for (const field of appearanceFields) selects[field].value = draft.appearance[field];
|
||||
const isValid = valid();
|
||||
nameInput.setAttribute("aria-invalid", isValid ? "false" : "true");
|
||||
saveButton.disabled = !isValid;
|
||||
status.textContent = isValid ? "" : "Enter a name from 1 to 64 characters without leading or trailing spaces.";
|
||||
}
|
||||
|
||||
function emitPreview(): void {
|
||||
if (valid()) options.onPreview?.(clone(draft));
|
||||
}
|
||||
|
||||
function assignField(field: AppearanceField, value: string): void {
|
||||
if (!(FIELD_OPTIONS[field] as readonly string[]).includes(value)) return;
|
||||
draft = { ...draft, appearance: { ...draft.appearance, [field]: value } } as LocalProfile;
|
||||
render();
|
||||
emitPreview();
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!valid()) {
|
||||
render();
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
committed = clone(draft);
|
||||
options.onSave?.(clone(committed));
|
||||
close();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
draft = clone(committed);
|
||||
render();
|
||||
emitPreview();
|
||||
options.onCancel?.();
|
||||
close();
|
||||
}
|
||||
|
||||
function close(): ProfileEditorState {
|
||||
if (!isOpen || disposed) return snapshot();
|
||||
isOpen = false;
|
||||
root.hidden = true;
|
||||
root.setAttribute("aria-hidden", "true");
|
||||
const target = returnFocus;
|
||||
returnFocus = null;
|
||||
target?.focus();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (disposed) return;
|
||||
draft = { ...draft, displayName: nameInput.value };
|
||||
render();
|
||||
emitPreview();
|
||||
});
|
||||
for (const field of appearanceFields) {
|
||||
selects[field].addEventListener("change", () => {
|
||||
if (!disposed) assignField(field, selects[field].value);
|
||||
});
|
||||
}
|
||||
resetButton.addEventListener("click", () => {
|
||||
if (disposed) return;
|
||||
const defaults = createDefaultLocalProfile(options.identityId, committed.displayName);
|
||||
draft = { ...defaults, displayName: draft.displayName };
|
||||
render();
|
||||
emitPreview();
|
||||
});
|
||||
cancelButton.addEventListener("click", () => {
|
||||
if (!disposed) cancel();
|
||||
});
|
||||
saveButton.addEventListener("click", () => {
|
||||
if (!disposed) save();
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
if (!disposed) save();
|
||||
});
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (disposed || !isOpen) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
save();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const enabled = focusOrder.filter((element) => !((element as HTMLButtonElement).disabled));
|
||||
const first = enabled[0];
|
||||
const last = enabled[enabled.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && doc.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && doc.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
state: snapshot,
|
||||
update(profile) {
|
||||
if (disposed) return snapshot();
|
||||
if (!isLocalProfile(profile)) throw new RangeError("profile editor: invalid profile");
|
||||
committed = clone(profile);
|
||||
draft = clone(profile);
|
||||
render();
|
||||
emitPreview();
|
||||
return snapshot();
|
||||
},
|
||||
open() {
|
||||
if (disposed || isOpen) return snapshot();
|
||||
isOpen = true;
|
||||
root.hidden = false;
|
||||
root.setAttribute("aria-hidden", "false");
|
||||
returnFocus = focusable(doc.activeElement) ? doc.activeElement : null;
|
||||
nameInput.focus();
|
||||
emitPreview();
|
||||
return snapshot();
|
||||
},
|
||||
close,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
if (isOpen) close();
|
||||
disposed = true;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function button(
|
||||
doc: Document,
|
||||
label: string,
|
||||
action: "reset" | "cancel" | "save",
|
||||
primary = false,
|
||||
): HTMLButtonElement {
|
||||
const element = doc.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = `tera-profile-editor__button${primary ? " tera-profile-editor__button--primary" : ""}`;
|
||||
element.setAttribute("data-action", action);
|
||||
element.textContent = label;
|
||||
return element;
|
||||
}
|
||||
@@ -49,3 +49,19 @@ export {
|
||||
type WebcamFaceConsentStatus,
|
||||
type WebcamFaceConsentTransition,
|
||||
} from "./webcamConsent.ts";
|
||||
|
||||
export {
|
||||
createProfileEditor,
|
||||
type ProfileEditor,
|
||||
type ProfileEditorOptions,
|
||||
type ProfileEditorState,
|
||||
} from "./editor.ts";
|
||||
|
||||
export {
|
||||
createWebcamFaceTexture,
|
||||
type WebcamFaceTextureAdapter,
|
||||
type WebcamFaceTextureBinding,
|
||||
type WebcamFaceTextureOptions,
|
||||
type WebcamFaceTextureState,
|
||||
type WebcamFaceTextureStatus,
|
||||
} from "./webcamFaceTexture.ts";
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Ephemeral webcam-face texture binding behind explicit consent.
|
||||
*
|
||||
* This adapter has no acquisition path: no `getUserMedia`, URL, fetch, canvas,
|
||||
* recorder, upload, or storage API. A caller that already owns a stream and a
|
||||
* video element may bind them only while the existing consent controller says
|
||||
* active. The adapter owns the resulting Three.js texture and nothing else.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { WebcamFaceConsentController } from "./webcamConsent.ts";
|
||||
|
||||
export type WebcamFaceTextureStatus = "off" | "active" | "disposed";
|
||||
|
||||
export interface WebcamFaceTextureState {
|
||||
status: WebcamFaceTextureStatus;
|
||||
active: boolean;
|
||||
/** UI must show this whenever live webcam pixels are available to the scene. */
|
||||
indicatorVisible: boolean;
|
||||
consentRevision: number;
|
||||
readonly ephemeral: true;
|
||||
readonly persistence: "none";
|
||||
readonly recording: false;
|
||||
readonly uploading: false;
|
||||
}
|
||||
|
||||
export interface WebcamFaceTextureBinding {
|
||||
/** Caller-owned and never stopped. */
|
||||
stream: MediaStream;
|
||||
/** Caller-owned and never removed, played, paused, or disposed. */
|
||||
video: HTMLVideoElement;
|
||||
}
|
||||
|
||||
export interface WebcamFaceTextureAdapter {
|
||||
state(): WebcamFaceTextureState;
|
||||
/**
|
||||
* Creates one adapter-owned `VideoTexture`. The caller must already have
|
||||
* attached the stream to the video and started playback after user consent.
|
||||
*/
|
||||
bind(binding: WebcamFaceTextureBinding): THREE.VideoTexture;
|
||||
/** Returns null and clears immediately when consent is no longer active. */
|
||||
texture(): THREE.VideoTexture | null;
|
||||
/** Explicitly reconcile a consent transition and return the new state. */
|
||||
sync(): WebcamFaceTextureState;
|
||||
clear(): WebcamFaceTextureState;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface WebcamFaceTextureOptions {
|
||||
consent: WebcamFaceConsentController;
|
||||
/** Active-indicator hook; fires only when the visible state changes. */
|
||||
onIndicatorChange?: (state: WebcamFaceTextureState) => void;
|
||||
}
|
||||
|
||||
export function createWebcamFaceTexture(options: WebcamFaceTextureOptions): WebcamFaceTextureAdapter {
|
||||
if (!options.consent || typeof options.consent.state !== "function") {
|
||||
throw new RangeError("webcam face texture: consent controller is required");
|
||||
}
|
||||
|
||||
let texture: THREE.VideoTexture | null = null;
|
||||
let disposed = false;
|
||||
let lastIndicator = false;
|
||||
|
||||
function snapshot(): WebcamFaceTextureState {
|
||||
const consent = options.consent.state();
|
||||
const active = !disposed && texture !== null && consent.status === "active" && consent.consentGranted;
|
||||
return {
|
||||
status: disposed ? "disposed" : active ? "active" : "off",
|
||||
active,
|
||||
indicatorVisible: active,
|
||||
consentRevision: consent.revision,
|
||||
ephemeral: true,
|
||||
persistence: "none",
|
||||
recording: false,
|
||||
uploading: false,
|
||||
};
|
||||
}
|
||||
|
||||
function announce(): WebcamFaceTextureState {
|
||||
const state = snapshot();
|
||||
if (state.indicatorVisible !== lastIndicator) {
|
||||
lastIndicator = state.indicatorVisible;
|
||||
options.onIndicatorChange?.({ ...state });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
if (!texture) return;
|
||||
// `VideoTexture.dispose()` releases only renderer resources. It does not
|
||||
// stop tracks or dispose/mutate the caller's video element.
|
||||
texture.dispose();
|
||||
texture = null;
|
||||
}
|
||||
|
||||
function reconcile(): WebcamFaceTextureState {
|
||||
const consent = options.consent.state();
|
||||
if (texture && (consent.status !== "active" || !consent.consentGranted)) release();
|
||||
return announce();
|
||||
}
|
||||
|
||||
return {
|
||||
state: reconcile,
|
||||
bind(binding) {
|
||||
if (disposed) throw new Error("webcam face texture: adapter is disposed");
|
||||
const consent = options.consent.state();
|
||||
if (consent.status !== "active" || !consent.consentGranted) {
|
||||
throw new Error("webcam face texture: explicit active consent is required");
|
||||
}
|
||||
validateBinding(binding);
|
||||
release();
|
||||
texture = new THREE.VideoTexture(binding.video);
|
||||
texture.name = "ephemeral-webcam-face";
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
announce();
|
||||
return texture;
|
||||
},
|
||||
texture() {
|
||||
reconcile();
|
||||
return texture;
|
||||
},
|
||||
sync: reconcile,
|
||||
clear() {
|
||||
if (!disposed) release();
|
||||
return announce();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
release();
|
||||
disposed = true;
|
||||
announce();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateBinding(binding: WebcamFaceTextureBinding): void {
|
||||
if (!binding || typeof binding !== "object") throw new TypeError("webcam face texture: binding is required");
|
||||
const stream = binding.stream as MediaStream | undefined;
|
||||
const video = binding.video as HTMLVideoElement | undefined;
|
||||
if (!stream || typeof stream.getVideoTracks !== "function") {
|
||||
throw new TypeError("webcam face texture: caller-owned MediaStream is required");
|
||||
}
|
||||
const tracks = stream.getVideoTracks();
|
||||
if (tracks.length === 0) throw new TypeError("webcam face texture: stream has no video track");
|
||||
if (!video || typeof video !== "object" || !("srcObject" in video)) {
|
||||
throw new TypeError("webcam face texture: caller-owned HTMLVideoElement is required");
|
||||
}
|
||||
if (video.srcObject !== stream) {
|
||||
throw new TypeError("webcam face texture: caller must attach the supplied stream to the video first");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user