Replace project with current server release and Windows setup

Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+9
View File
@@ -0,0 +1,9 @@
/** Device-local, reversible camera experiment. Photos and optical settings are untouched. */
export type CameraComposition = 'wall' | 'legacy';
const KEY = 'behind-glass-camera-composition-v32';
export function readCameraComposition(storage?:Pick<Storage,'getItem'>):CameraComposition {
try {return storage?.getItem(KEY)==='legacy'?'legacy':'wall';} catch {return 'wall';}
}
export function saveCameraComposition(storage:Pick<Storage,'setItem'>|undefined,mode:CameraComposition):boolean {
try {if(!storage)return false;storage.setItem(KEY,mode);return true;} catch {return false;}
}
+93 -4
View File
@@ -1,9 +1,98 @@
import * as THREE from 'three';
import type {CameraComposition} from './CameraComposition';
import {roomComposition} from '../objects/RoomComposition';
export class CameraController {
readonly target = new THREE.Vector2(); readonly current = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.5) {}
setMaxOffset(value: number): void { this.maxOffset = THREE.MathUtils.clamp(value, 0, 0.9); }
add(x: number, y: number): void { this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
private composition:CameraComposition='legacy';
setCompositionMode(mode:CameraComposition):void {this.composition=mode;}
private room: {width:number;height:number;glassZ:number}|null=null;
private readonly neutralNear:number;
private readonly neutralFov:number;
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.75) {this.neutralNear=camera.near;this.neutralFov=camera.fov;}
/** The house uses the same two-axis gesture on a bounded approach path. */
setRoomBounds(width:number,height:number,glassZ:number,enabled=true):void {
if(![width,height,glassZ].every(Number.isFinite)||width<=0||height<=0)return;
this.room=enabled?{width,height,glassZ}:null;
// A very short panoramic opening puts the floor close to neutral eye height.
// Fit near to that real clearance; preserve the anchored projection center.
const floorDepth=height/2+.24*Math.min(width,height),near=enabled?Math.min(this.neutralNear,floorDepth*.35,roomComposition(width,height,glassZ).halfWidth*.1):this.neutralNear;
if(this.camera.near!==near){
const px=this.camera.projectionMatrix.elements[8],py=this.camera.projectionMatrix.elements[9];
this.camera.near=near;this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8]=px;this.camera.projectionMatrix.elements[9]=py;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
}
private setFov(value:number):void {
if(this.camera.fov===value)return;
const px=this.camera.projectionMatrix.elements[8],py=this.camera.projectionMatrix.elements[9];
this.camera.fov=value;this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8]=px;this.camera.projectionMatrix.elements[9]=py;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
get isMoving():boolean {return this.target.distanceToSquared(this.current)>1e-12;}
setMaxOffset(value: number): void { if (Number.isFinite(value)) this.maxOffset = THREE.MathUtils.clamp(value, 0.35, 0.90); }
add(x: number, y: number): void { if(Number.isFinite(x)&&Number.isFinite(y))this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
setView(x: number, y: number): void { if (Number.isFinite(x) && Number.isFinite(y)) this.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)); }
/** Stop residual smoothing so pinch anchors stay under the fingers. */
hold(): void { this.target.copy(this.current); }
reset(): void { this.target.set(0, 0); this.current.set(0, 0); }
update(dt: number, sensitivity: number): THREE.Vector2 { this.current.lerp(this.target, 1 - Math.exp(-8 * dt)); const view = this.current.clone().multiplyScalar(sensitivity); view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1); this.camera.position.set(view.x * this.maxOffset, view.y * this.maxOffset, 3); this.camera.lookAt(0, 0, 0); return view; }
update(dt: number, sensitivity: number): THREE.Vector2 {
this.current.lerp(this.target, 1 - Math.exp(-8 * Math.max(0,dt)));
if(!this.isMoving)this.current.copy(this.target);
const view = this.current.clone().multiplyScalar(sensitivity);
view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1);
if(!this.room){this.setFov(this.neutralFov);this.camera.position.set(view.x*this.maxOffset,view.y*this.maxOffset,3);this.camera.lookAt(0,0,0);return view;}
const {width,height,glassZ}=this.room,scale=Math.min(width,height);
const strength=this.maxOffset/.75,travel=Math.max(Math.abs(view.x),Math.abs(view.y));
// Approach is coupled to the existing lean, not zoom or a new walking control.
// Smoothstep leaves neutral motion gentle and arrives at the same safe wall distance.
const phase=THREE.MathUtils.clamp((travel-.15)/.85*strength,0,1),approach=phase*phase*(3-2*phase);
const front=glassZ+.4471*scale+.24;
const floor=-height/2-.24*scale;
const lateral=Math.max(.75,width/2+.20*scale)*strength;
const lower=Math.min(this.maxOffset,-floor*.5);
this.camera.position.set(view.x*lateral,view.y*(view.y<0?lower:this.maxOffset),THREE.MathUtils.lerp(3,front,approach));
if(this.composition==='wall') {
const horizontal=THREE.MathUtils.smoothstep(Math.abs(view.x)*strength,.35,1);
const bounds=roomComposition(width,height,glassZ),side=Math.sign(view.x);
const eyeY=Math.max(bounds.floor+.35*bounds.unit,view.y*Math.min(this.maxOffset*.4,bounds.unit*.3));
const portrait=1-THREE.MathUtils.smoothstep(this.camera.aspect,.7,1);
const wallEye=new THREE.Vector3(side*.65*bounds.halfWidth,eyeY,glassZ+.6*bounds.unit);
const gaze=new THREE.Vector3(-side,0,-Math.tan(THREE.MathUtils.degToRad(25)));
const wallAim=wallEye.clone().addScaledVector(gaze,1.65*bounds.halfWidth);
// Keep the close landscape pose and neutral pet size. Only late sideways
// travel in portrait retreats enough to share the narrow frame with the art.
wallEye.z+=2.6*bounds.unit*portrait;
if(portrait>0){
// Match the shallow wall's actual painting center. Bisect the two target
// directions, not their world positions, so the nearby aperture keeps its share.
const picture=new THREE.Vector3(-side*(bounds.halfWidth-.018*bounds.unit),Math.min(.08*height,bounds.floor+1.05*bounds.unit),glassZ+.07*scale+.5*bounds.unit);
const towardDoor=new THREE.Vector3(0,0,glassZ).sub(wallEye).normalize();
const towardPicture=picture.sub(wallEye).normalize();
const portraitAim=wallEye.clone().addScaledVector(towardDoor.add(towardPicture).normalize(),1.65*bounds.halfWidth);
wallAim.lerp(portraitAim,portrait);
}
this.camera.position.lerp(wallEye,horizontal);
this.camera.position.x=THREE.MathUtils.clamp(this.camera.position.x,-.78*bounds.halfWidth,.78*bounds.halfWidth);
const aim=new THREE.Vector3(0,0,glassZ*approach).lerp(wallAim,horizontal);
this.setFov(THREE.MathUtils.lerp(this.neutralFov,64,portrait*horizontal));this.camera.lookAt(aim);return view;
}
// Late horizontal input opens into the room, preserving the central pet/door
// experience and vertical approach. A modest portrait-only lens ramp and
// bounded retreat expose the corner without the earlier extreme close-up.
const horizontal=THREE.MathUtils.smoothstep(Math.abs(view.x)*strength,.35,1);
const bounds=roomComposition(width,height,glassZ),side=Math.sign(view.x);
const portrait=1-THREE.MathUtils.smoothstep(this.camera.aspect,.7,1.4);
const eyeY=Math.min(0,bounds.floor+.8*bounds.unit);
const cornerEye=new THREE.Vector3(side*.75*bounds.halfWidth,Math.max(bounds.floor+.4*bounds.unit,eyeY+view.y*Math.min(this.maxOffset*.4,bounds.unit*.3)),glassZ+THREE.MathUtils.lerp(2,3.7,portrait)*bounds.unit);
this.camera.position.lerp(cornerEye,horizontal);
this.camera.position.x=THREE.MathUtils.clamp(this.camera.position.x,-.78*bounds.halfWidth,.78*bounds.halfWidth);
const aim=new THREE.Vector3(0,0,glassZ*approach).lerp(new THREE.Vector3(-side*.68*bounds.halfWidth,eyeY-THREE.MathUtils.lerp(.2,-.05,portrait)*bounds.unit+view.y*.12*bounds.unit,glassZ+.1*scale),horizontal);
const roomFov=THREE.MathUtils.lerp(this.neutralFov,60,portrait);
this.setFov(THREE.MathUtils.lerp(this.neutralFov,roomFov,horizontal));
this.camera.lookAt(aim);
return view;
}
}
+157 -18
View File
@@ -1,29 +1,168 @@
export type GyroState = 'off' | 'permission' | 'waiting' | 'active' | 'paused' | 'unavailable';
type View = { x: number; y: number };
type Rotation = { x: number; y: number; z: number; w: number };
const radians = Math.PI / 180;
const clamp = (value: number): number => Math.max(-1, Math.min(1, value));
// DeviceOrientation uses intrinsic Z-X'-Y'' rotations. Relative quaternions avoid
// beta wrapping and equivalent Euler-angle flips around an upright phone.
function rotation(alpha: number, beta: number, gamma: number): Rotation {
const a = alpha * radians / 2, b = beta * radians / 2, g = gamma * radians / 2;
const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b), cg = Math.cos(g), sg = Math.sin(g);
return { x: ca * sb * cg - sa * cb * sg, y: ca * cb * sg + sa * sb * cg, z: sa * cb * cg + ca * sb * sg, w: ca * cb * cg - sa * sb * sg };
}
function relativeTilt(base: Rotation, pose: Rotation): View {
const x = base.w * pose.x - base.x * pose.w - base.y * pose.z + base.z * pose.y;
const y = base.w * pose.y + base.x * pose.z - base.y * pose.w - base.z * pose.x;
const z = base.w * pose.z - base.x * pose.y + base.y * pose.x - base.z * pose.w;
const w = base.w * pose.w + base.x * pose.x + base.y * pose.y + base.z * pose.z;
const length = Math.hypot(x, y, z);
const scale = length < 1e-9 ? 0 : 2 * Math.atan2(length, Math.abs(w)) / length * (w < 0 ? -1 : 1) / (35 * radians);
return { x: y * scale, y: x * scale };
}
/** Opt-in, local-only input. CameraController owns the existing time-based smoothing. */
export class GyroController {
state: GyroState = 'off';
private enabled = false;
private pending = false;
private generation = 0;
private paused = false;
private disposed = false;
private hasOutput = false;
private hasReading = false;
private base: { beta: number; gamma: number } | null = null;
constructor(private readonly setView: (x: number, y: number) => void, private readonly status: (message: string) => void) {}
private base: Rotation | null = null;
private intentPose: Rotation | null = null;
private baseView: View = { x: 0, y: 0 };
private timer: number | undefined;
private angle = 0;
private readonly orientation = window.screen?.orientation;
constructor(
private readonly setView: (x: number, y: number) => void,
private readonly status: (state: GyroState, message: string) => void,
private readonly getView: () => View,
private readonly onIntent: () => void = () => {},
) {
document.addEventListener('visibilitychange', this.visibility);
window.addEventListener('pagehide', this.pagehide);
window.addEventListener('orientationchange', this.reorient);
this.orientation?.addEventListener('change', this.reorient);
}
private report(state: GyroState, message: string): void { this.state = state; this.status(state, message); }
private clearTimer(): void { if (this.timer !== undefined) window.clearTimeout(this.timer); this.timer = undefined; }
private waitForReading(): void {
this.clearTimer();
// The API is change-driven: silence after a proved reading can mean stillness.
if (this.hasReading) return;
this.timer = window.setTimeout(() => this.fail('기울기 신호를 받지 못했어요. 드래그로 둘러보세요.'), 4000);
}
private readAngle(): number {
const value = window.screen?.orientation?.angle ?? (window as Window & { orientation?: number }).orientation ?? 0;
return Number.isFinite(value) ? value : 0;
}
private detach(): void { window.removeEventListener('deviceorientation', this.onOrientation); this.clearTimer(); }
private listen(): void {
if (!this.enabled || document.hidden) return;
this.angle = this.readAngle();
window.addEventListener('deviceorientation', this.onOrientation);
this.report(this.hasReading ? 'active' : 'waiting', this.hasReading ? '기울기 켜짐 · 현재 자세에서 이어갈게요.' : '휴대폰을 편하게 들고 잠깐 기다려 주세요.');
this.waitForReading();
}
private stop(): void {
++this.generation; this.pending = false; this.enabled = false; this.base = null; this.intentPose = null; this.hasOutput = false; this.hasReading = false; this.detach();
}
private fail(message: string): void { this.stop(); this.report('unavailable', message); }
/** Explicitly turn off without ever requesting permission or toggling back on. */
disconnect(): void {
if(this.disposed)return;
this.stop();this.paused=false;this.baseView={x:0,y:0};
this.report('off','기울기는 꺼져 있어요. 드래그로 둘러볼 수 있어요.');
}
/** Call directly from a click: Safari permission is requested before the first await. */
async toggle(): Promise<boolean> {
if (this.enabled || this.pending) { this.dispose(); return false; }
if (!window.isSecureContext || !('DeviceOrientationEvent' in window)) { this.status('자이로스코프를 사용할 수 없습니다.'); return false; }
const generation = ++this.generation; this.pending = true;
if (this.disposed) return false;
if (this.enabled || this.pending) { this.stop(); this.report('off', '기울기 조작을 껐어요. 드래그로 둘러보세요.'); return false; }
if (!window.isSecureContext || !window.DeviceOrientationEvent) { this.fail('이 환경에서는 기울기를 사용할 수 없어요. 드래그로 둘러보세요.'); return false; }
const generation = ++this.generation;
this.pending = true;
try {
const device = window.DeviceOrientationEvent as typeof DeviceOrientationEvent & { requestPermission?: () => Promise<string> };
this.status('센서 권한을 기다리는 중…');
if (device.requestPermission && await device.requestPermission() !== 'granted') throw new Error('권한 거절됨');
if (generation !== this.generation) return false;
this.base = null; this.hasReading = false; this.pending = false; this.enabled = true; window.addEventListener('deviceorientation', this.onOrientation); this.status('센서값을 기다리는 중…'); return true;
} catch (error) { this.pending = false; if (generation === this.generation) this.status(`자이로스코프를 사용할 수 없습니다 (${(error as Error).message})`); return false; }
if (typeof device.requestPermission === 'function') {
this.report('permission', '브라우저에서 동작 및 방향 접근을 선택해 주세요.');
const permission = await device.requestPermission();
if (generation !== this.generation) return false;
if (permission !== 'granted') { this.fail('기울기 접근이 허용되지 않았어요. 드래그로 둘러보세요.'); return false; }
}
if (generation !== this.generation || this.disposed) return false;
if (document.hidden) { this.stop(); this.report('off', '화면으로 돌아온 뒤 기울여 보기를 눌러 주세요.'); return false; }
this.pending = false; this.enabled = true; this.base = null; this.intentPose = null; this.baseView = { x: 0, y: 0 }; this.hasOutput = false;
this.listen(); return true;
} catch {
if (generation === this.generation) this.fail('기울기 접근을 시작하지 못했어요. 드래그로 둘러보세요.');
return false;
}
}
reset(): void { this.base = null; }
dispose(): void { ++this.generation; this.pending = false; this.enabled = false; this.base = null; window.removeEventListener('deviceorientation', this.onOrientation); }
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || event.beta == null || event.gamma == null) return;
if (!this.base) this.base = { beta: event.beta, gamma: event.gamma };
const angle = (screen.orientation?.angle ?? 0) * Math.PI / 180;
const x = event.gamma - this.base.gamma; const y = event.beta - this.base.beta;
this.setView((x * Math.cos(angle) + y * Math.sin(angle)) / 35, (y * Math.cos(angle) - x * Math.sin(angle)) / 35); if (!this.hasReading) { this.hasReading = true; this.status('자이로스코프 활성'); }
/** Pause for scene overlays and any pointer gesture; resumption never replays old motion. */
setPaused(paused: boolean): void {
if (this.paused === paused) return;
this.paused = paused;
if (!this.enabled) return;
this.rebase();
if (paused) this.report('paused', '기울기 켜짐 · 화면 조작을 마치면 이어져요.');
else if (!document.hidden) { this.report(this.hasReading ? 'active' : 'waiting', '현재 자세를 기준으로 이어갈게요.'); this.waitForReading(); }
}
private rebase(): void {
this.base = null; this.intentPose = null;
this.baseView = this.hasOutput ? this.getView() : { x: 0, y: 0 };
}
/** Keep keyboard/manual input when the next sensor reading arrives. */
syncManualView(): void { this.hasOutput = true; this.rebase(); }
reset(): void {
this.base = null; this.intentPose = null; this.baseView = { x: 0, y: 0 }; this.hasOutput = false;
if (this.enabled && !document.hidden) { this.report(this.hasReading ? 'active' : 'waiting', '현재 자세를 정면으로 맞출게요.'); this.waitForReading(); }
}
private reorient = (): void => {
if (!this.enabled) return;
this.angle = this.readAngle(); this.rebase();
if (!document.hidden) this.waitForReading();
};
private visibility = (): void => {
if (document.hidden) {
if (this.pending) { this.stop(); this.report('off', '화면으로 돌아온 뒤 기울여 보기를 눌러 주세요.'); }
else if (this.enabled) { this.detach(); this.rebase(); this.report('paused', '기울기 켜짐 · 화면으로 돌아오면 이어져요.'); }
} else if (this.enabled) { this.rebase(); this.listen(); }
};
private pagehide = (): void => { this.stop(); this.report('off', '다시 기울여 보기를 눌러 시작할 수 있어요.'); };
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || document.hidden) return;
const { alpha, beta, gamma } = event;
if (alpha === null || beta === null || gamma === null || ![alpha, beta, gamma].every(Number.isFinite)) return;
this.clearTimer(); this.hasReading = true;
const angle = this.readAngle();
if (angle !== this.angle) { this.angle = angle; this.rebase(); }
const pose = rotation(alpha, beta, gamma);
if (this.paused) { this.base = null; this.intentPose = null; if (this.state !== 'paused') this.report('paused', '기울기 켜짐 · 설정을 닫고 휴대폰을 살짝 기울여 보세요.'); return; }
if (!this.base) this.base = pose;
if (!this.intentPose) this.intentPose = pose;
const intent = relativeTilt(this.intentPose, pose);
// About 4.2 degrees from the last deliberate pose, not sensor jitter or
// holding a tilted phone. This only teaches hints; camera mapping is untouched.
if (Math.hypot(intent.x, intent.y) > .12) { this.intentPose = pose; this.onIntent(); }
const tilt = relativeTilt(this.base, pose), screenAngle = this.angle * radians;
this.setView(clamp(this.baseView.x + tilt.x * Math.cos(screenAngle) + tilt.y * Math.sin(screenAngle)), clamp(this.baseView.y + tilt.y * Math.cos(screenAngle) - tilt.x * Math.sin(screenAngle)));
this.hasOutput = true;
if (this.state !== 'active') this.report('active', '기울기 켜짐 · 현재 자세가 기준이에요.');
};
dispose(): void {
this.stop(); this.disposed = true;
document.removeEventListener('visibilitychange', this.visibility);
window.removeEventListener('pagehide', this.pagehide);
window.removeEventListener('orientationchange', this.reorient);
this.orientation?.removeEventListener('change', this.reorient);
}
}
+21
View File
@@ -0,0 +1,21 @@
/** Reads only an explicit browser paste event; never requests clipboard access. */
export function initImagePaste(root: Document, signal: AbortSignal, enabled: () => boolean, onImage: (file: File) => boolean): void {
const editable = (target: EventTarget | null): boolean => {
const element = target as HTMLElement | null;
return Boolean(element?.isContentEditable || element?.closest?.('input, textarea, select, [role="textbox"], [contenteditable]:not([contenteditable="false"])'));
};
root.addEventListener('paste', event => {
if (signal.aborted || event.defaultPrevented || !enabled() || root.querySelector?.('dialog[open]')) return;
if (editable(event.target) || editable(root.activeElement) || event.composedPath?.().some(editable)) return;
const data = event.clipboardData;
if (!data) return;
let file: File | null = null;
for (const item of Array.from(data.items ?? [])) {
if (item.kind !== 'file' || !item.type.startsWith('image/')) continue;
const candidate = item.getAsFile();
if (candidate?.type.startsWith('image/')) { file = candidate; break; }
}
if (!file) file = Array.from(data.files ?? []).find(candidate => candidate.type.startsWith('image/')) ?? null;
if (file && onImage(file)) event.preventDefault();
}, { signal });
}
+30 -19
View File
@@ -1,10 +1,13 @@
import * as THREE from 'three';
import { createWorkingPhotoImage, type WorkingPhotoImage } from './WorkingPhotoImage';
export type MediaReady = (texture: THREE.Texture, aspect: number, video: HTMLVideoElement | null) => void;
export class MediaLoader {
private token = 0;
private activeUrl = '';
private pendingPhoto: AbortController | null = null;
private activePhoto: WorkingPhotoImage | null = null;
private pendingUrl = '';
private pendingVideo: HTMLVideoElement | null = null;
private activeVideo: HTMLVideoElement | null = null;
@@ -12,30 +15,37 @@ export class MediaLoader {
constructor(private readonly onReady: MediaReady, private readonly onStatus: (message: string) => void) {}
cancelPending(): void { ++this.token; this.clearPending(); this.retryVideo = null; }
cancelPending(preserveActiveVideoRetry = false): void { ++this.token; this.clearPending(); this.retryVideo = preserveActiveVideoRetry ? this.activeVideo : null; }
load(file: File): void {
const token = ++this.token;
this.clearPending();
this.retryVideo = null;
const url = URL.createObjectURL(file);
this.pendingUrl = url;
if (file.type.startsWith('image/')) {
const image = new Image();
image.onload = () => {
if (token !== this.token) return this.disposeUrl(url);
if (this.pendingUrl === url) this.pendingUrl = '';
const texture = new THREE.Texture(image);
const abort = new AbortController();
this.pendingPhoto = abort;
void createWorkingPhotoImage(file, token, abort.signal).then(photo => {
if (token !== this.token || abort.signal.aborted) { photo.dispose(); return; }
this.pendingPhoto = null;
const texture = new THREE.Texture(photo.image);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
this.replaceActive(url, null);
this.onReady(texture, image.width / image.height, null);
// Resize/diagnostics may still draw the previous image inside onReady.
const previous = this.replaceActive('', null);
this.activePhoto = photo;
try { this.onReady(texture, photo.image.width / photo.image.height, null); }
catch (error) { texture.dispose(); photo.dispose(); if (this.activePhoto === photo) this.activePhoto = null; throw error; }
finally { previous?.dispose(); }
if (token === this.token) this.onStatus('');
};
image.onerror = () => { if (token === this.token) this.onStatus('이미지를 읽지 못했습니다.'); this.disposeUrl(url); };
image.src = url;
}).catch((error: unknown) => {
if (token !== this.token || abort.signal.aborted) return;
this.pendingPhoto = null;
this.onStatus(error instanceof Error ? error.message : '이미지를 읽지 못했습니다.');
});
return;
}
const url = URL.createObjectURL(file);
this.pendingUrl = url;
const video = document.createElement('video');
this.pendingVideo = video;
video.muted = true; video.loop = true; video.playsInline = true; video.preload = 'auto'; video.src = url;
@@ -57,9 +67,10 @@ export class MediaLoader {
if (this.pendingUrl === url) this.pendingUrl = '';
if (this.pendingVideo === video) this.pendingVideo = null;
const texture = new THREE.VideoTexture(video); texture.colorSpace = THREE.SRGBColorSpace;
this.replaceActive(url, video);
const previous = this.replaceActive(url, video);
this.retryVideo = video;
this.onReady(texture, video.videoWidth / video.videoHeight, video);
try { this.onReady(texture, video.videoWidth / video.videoHeight, video); }
finally { previous?.dispose(); }
if (token === this.token && this.activeVideo === video) this.onStatus('');
video.play().catch(() => {
if (token === this.token && video === this.activeVideo) this.onStatus('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');
@@ -68,10 +79,10 @@ export class MediaLoader {
video.load();
}
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().then(() => { if (token === this.token && video === this.activeVideo) this.onStatus(''); }).catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; }
private replaceActive(url: string, video: HTMLVideoElement | null): void { if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; }
private clearPending(): void { if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; this.activePhoto?.dispose(); this.activePhoto = null; }
private replaceActive(url: string, video: HTMLVideoElement | null): WorkingPhotoImage | null { const previous = this.activePhoto; this.activePhoto = null; if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; return previous; }
private clearPending(): void { this.pendingPhoto?.abort(); this.pendingPhoto = null; if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
private disposeUrl(url: string): void { if (url) URL.revokeObjectURL(url); }
}
+149
View File
@@ -0,0 +1,149 @@
export const PHOTO_HEADER_BYTES = 256 * 1024;
export type PhotoMetadata = {
width: number;
height: number;
orientation: number | null;
};
/** Bounded metadata read, never an original-image upload or full encoded-file copy. */
export async function readPhotoMetadata(file: Blob): Promise<PhotoMetadata | null> {
if (typeof file.slice !== 'function')
return null;
try {
const buffer = await file.slice(0, PHOTO_HEADER_BYTES).arrayBuffer(), b = new Uint8Array(buffer), v = new DataView(buffer);
let width = 0, height = 0;
let orientation: number | null = null;
if (b.length >= 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) {
width = v.getUint32(16);
height = v.getUint32(20);
orientation = await chunkOrientation(file, b, true);
}
else if (b.length >= 30 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') {
const type = String.fromCharCode(...b.slice(12, 16));
orientation = type !== 'VP8X' || !(b[20] & 8) ? 1 : await chunkOrientation(file, b, false);
if (type === 'VP8X') {
width = 1 + b[24] + (b[25] << 8) + (b[26] << 16);
height = 1 + b[27] + (b[28] << 8) + (b[29] << 16);
}
else if (type === 'VP8 ' && b[23] === 157 && b[24] === 1 && b[25] === 42) {
width = v.getUint16(26, true) & 16383;
height = v.getUint16(28, true) & 16383;
}
else if (type === 'VP8L' && b[20] === 47) {
width = 1 + ((b[22] & 63) << 8) + b[21];
height = 1 + ((b[24] & 15) << 10) + (b[23] << 2) + (b[22] >> 6);
}
}
else if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
for (let p = 2; p + 4 <= b.length;) {
if (b[p] !== 255)
break;
const marker = b[p + 1];
if (marker === 217 || marker === 218) {
orientation ??= 1;
break;
}
if (marker === 255) {
p++;
continue;
}
const length = v.getUint16(p + 2), end = p + 2 + length;
if (length < 2 || end > b.length)
break;
if (marker === 225 && length >= 16 && String.fromCharCode(...b.slice(p + 4, p + 10)) === 'Exif\0\0') {
const base = p + 10, little = v.getUint16(base) === 0x4949;
if (v.getUint16(base + 2, little) === 42) {
const ifd = base + v.getUint32(base + 4, little);
if (ifd >= base && ifd + 2 <= end) {
const entries = v.getUint16(ifd, little);
for (let n = 0; n < entries; n++) {
const at = ifd + 2 + n * 12;
if (at + 12 > end)
break;
if (v.getUint16(at, little) === 0x112 && v.getUint16(at + 2, little) === 3 && v.getUint32(at + 4, little) === 1) {
const o = v.getUint16(at + 8, little);
if (o >= 1 && o <= 8)
orientation = o;
}
}
}
}
}
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && length >= 7) {
height = v.getUint16(p + 5);
width = v.getUint16(p + 7);
}
p = end;
}
}
// Keep browser-supported GIF/BMP input working without an unbounded probe.
else if (b.length >= 10 && String.fromCharCode(...b.slice(0, 3)) === 'GIF') {
width = v.getUint16(6, true); height = v.getUint16(8, true); orientation = 1;
}
else if (b.length >= 26 && b[0] === 66 && b[1] === 77) {
const headerSize = v.getUint32(14, true);
if (headerSize === 12) { width = v.getUint16(18, true); height = v.getUint16(20, true); }
else if (headerSize >= 40) { width = v.getInt32(18, true); height = Math.abs(v.getInt32(22, true)); }
orientation = 1;
}
if (width < 1 || height < 1 || !Number.isSafeInteger(width * height))
return null;
return orientation !== null && orientation >= 5 ? { width: height, height: width, orientation } : { width, height, orientation };
}
catch {
return null;
}
}
/** Read only chunk headers and EXIF, skipping encoded image payloads (including
* EXIF after a many-megabyte PNG IDAT/WebP VP8 chunk). Incomplete scans stay
* unknown: never resize using an unverified unrotated aspect ratio. */
async function chunkOrientation(file: Blob, prefix: Uint8Array, png: boolean): Promise<number | null> {
let budget = PHOTO_HEADER_BYTES;
const read = async (offset: number, length: number): Promise<Uint8Array | null> => {
if (length > budget || offset < 0 || offset + length > file.size) return null;
if (offset + length <= prefix.length) return prefix.subarray(offset, offset + length);
budget -= length;
return new Uint8Array(await file.slice(offset, offset + length).arrayBuffer());
};
const length = png ? file.size : Math.min(file.size, new DataView(prefix.buffer, prefix.byteOffset, prefix.byteLength).getUint32(4, true) + 8);
let offset = png ? 8 : 12;
for (let chunks = 0; chunks < 512 && offset + 8 <= length; chunks++) {
const header = await read(offset, 8);
if (!header || header.length < 8) return null;
const v = new DataView(header.buffer, header.byteOffset, header.byteLength);
const size = v.getUint32(png ? 0 : 4, !png);
const type = String.fromCharCode(...header.subarray(png ? 4 : 0, png ? 8 : 4));
const next = offset + 8 + size + (png ? 4 : size % 2);
if (next > length || next <= offset) return null;
if (type === (png ? 'eXIf' : 'EXIF')) {
const data = await read(offset + 8, size);
return data ? exifOrientation(data) : null;
}
if (png && type === 'IEND') return 1;
offset = next;
}
return !png && offset === length ? 1 : null;
}
function exifOrientation(bytes: Uint8Array): number | null {
const base = String.fromCharCode(...bytes.subarray(0, 6)) === 'Exif\0\0' ? 6 : 0;
if (bytes.length < base + 8) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const order = view.getUint16(base);
if (order !== 0x4949 && order !== 0x4d4d) return null;
const little = order === 0x4949;
if (view.getUint16(base + 2, little) !== 42) return null;
const ifd = base + view.getUint32(base + 4, little);
if (ifd < base + 8 || ifd + 2 > bytes.length) return null;
const count = view.getUint16(ifd, little);
for (let n = 0; n < count; n++) {
const at = ifd + 2 + n * 12;
if (at + 12 > bytes.length) return null;
if (view.getUint16(at, little) === 0x112) {
if (view.getUint16(at + 2, little) !== 3 || view.getUint32(at + 4, little) !== 1) return null;
const orientation = view.getUint16(at + 8, little);
return orientation >= 1 && orientation <= 8 ? orientation : null;
}
}
return 1;
}
+96
View File
@@ -0,0 +1,96 @@
import type { PhotoSnapshot } from '../types/photo';
import { readPhotoMetadata } from './PhotoMetadata';
export const PHOTO_WORKING_LONG_EDGE = 1024;
export const PHOTO_MAX_SOURCE_PIXELS = 64_000_000;
export const PHOTO_MAX_SOURCE_EDGE = 32768;
export function photoWorkingSize(width: number, height: number): { width: number; height: number } {
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1)
throw new Error('사진 크기를 확인할 수 없어요.');
if (width > PHOTO_MAX_SOURCE_EDGE || height > PHOTO_MAX_SOURCE_EDGE || width * height > PHOTO_MAX_SOURCE_PIXELS)
throw new Error('사진이 너무 커요. 긴 변 8,000픽셀 이하로 줄인 사본을 선택해 주세요.');
const scale = Math.min(1, PHOTO_WORKING_LONG_EDGE / Math.max(width, height));
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
}
/** One oriented working image for display and depth. Never modifies File.
* Resized decode is a browser hint, not a bound on internal decoder peak RAM. */
export async function createPhotoSnapshot(file: File, requestId: number, signal: AbortSignal): Promise<PhotoSnapshot> {
signal.throwIfAborted();
const header = await readPhotoMetadata(file);
signal.throwIfAborted();
const target = header ? photoWorkingSize(header.width, header.height) : null;
if (typeof createImageBitmap !== 'function')
throw new Error('이 브라우저에서 사진 준비를 지원하지 않아요. 최신 브라우저에서 다시 열어 주세요.');
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image', premultiplyAlpha: 'none',
...(target && header?.orientation !== null ? { resizeWidth: target.width, resizeHeight: target.height, resizeQuality: 'high' as const } : {}) });
} catch {
signal.throwIfAborted();
// Some browsers decode images but do not support Blob resize options.
bitmap = await decodeViaImage(file, signal);
}
try {
signal.throwIfAborted();
const size = photoWorkingSize(bitmap.width, bitmap.height);
if (size.width !== bitmap.width || size.height !== bitmap.height) {
const old = bitmap;
let resized: ImageBitmap | null = null;
try {
resized = await createImageBitmap(old, { resizeWidth: size.width, resizeHeight: size.height, resizeQuality: 'high', premultiplyAlpha: 'none' });
} catch { /* Older browsers may reject resize options. */ }
if (resized && (resized.width !== size.width || resized.height !== size.height)) {
if (resized !== old) resized.close();
resized = null; // Unknown dictionary options can also be silently ignored.
}
if (!resized) {
signal.throwIfAborted();
resized = await canvasBitmap(old, size.width, size.height);
}
bitmap = resized;
if (bitmap !== old) old.close();
}
signal.throwIfAborted();
if (Math.max(bitmap.width, bitmap.height) > PHOTO_WORKING_LONG_EDGE)
throw new Error('브라우저가 사진 크기를 줄이지 못했어요. 작은 사본을 선택해 주세요.');
return { requestId, width: bitmap.width, height: bitmap.height, bitmap };
} catch (error) {
bitmap.close();
throw error;
}
}
async function canvasBitmap(source: CanvasImageSource, width: number, height: number): Promise<ImageBitmap> {
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
try {
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 준비할 수 없어요.');
context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high';
context.drawImage(source, 0, 0, width, height);
return await createImageBitmap(canvas, { premultiplyAlpha: 'none' });
} finally { canvas.width = canvas.height = 1; }
}
async function decodeViaImage(file: File, signal: AbortSignal): Promise<ImageBitmap> {
const image = new Image(), url = URL.createObjectURL(file);
try {
await new Promise<void>((resolve, reject) => {
const cleanup = () => { image.onload = image.onerror = null; signal.removeEventListener('abort', abort); };
const abort = () => { cleanup(); reject(new DOMException('사진 준비를 취소했어요.', 'AbortError')); };
image.onload = () => { cleanup(); resolve(); };
image.onerror = () => { cleanup(); reject(new Error('사진을 읽지 못했어요. HEIC 등 지원되지 않는 형식은 JPEG 또는 PNG 사본으로 선택해 주세요.')); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort(); else image.src = url;
});
signal.throwIfAborted();
const size = photoWorkingSize(image.naturalWidth || image.width, image.naturalHeight || image.height);
const bitmap = await canvasBitmap(image, size.width, size.height);
if (signal.aborted) { bitmap.close(); signal.throwIfAborted(); }
return bitmap;
} finally {
image.onload = image.onerror = null; image.removeAttribute('src'); URL.revokeObjectURL(url);
}
}
+196 -7
View File
@@ -1,10 +1,199 @@
import type { SceneAnchor } from './SceneZoomController';
export interface ZoomGestureOptions {
getZoom: () => number;
setZoom: (zoom: number, from: SceneAnchor, to: SceneAnchor) => void;
/** Gates all scene gestures, including the existing one-pointer view drag. */
isEnabled?: () => boolean;
/** Optional comparison pane bounds in client coordinates. Null means outside a pane. */
getBounds?: (point: SceneAnchor) => { left: number; top: number; width: number; height: number } | null;
/** Optional handle-only subject drag; a second touch cancels it before pinch. */
subjectDrag?: { begin: (point: SceneAnchor) => boolean; move: (point: SceneAnchor) => void; end: (cancelled: boolean) => void };
onGestureChange?: (active: boolean, pinching: boolean) => void;
}
interface ActivePointer { x: number; y: number; type: string }
export class PointerController {
private activeId: number | null = null;
private lastX = 0; private lastY = 0;
private readonly pointers = new Map<number, ActivePointer>();
private pinchDistance = 0;
private hasDragged = false;
constructor(private readonly element: HTMLElement, private readonly setView: (x: number, y: number) => void, private readonly onDrag?: () => void) { element.addEventListener('pointerdown', this.down); element.addEventListener('pointermove', this.move); element.addEventListener('pointerup', this.end); element.addEventListener('pointercancel', this.end); }
private down = (event: PointerEvent): void => { this.activeId = event.pointerId; this.lastX = event.clientX; this.lastY = event.clientY; this.element.setPointerCapture(event.pointerId); };
private move = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; const dx = event.clientX - this.lastX; const dy = event.clientY - this.lastY; if (dx === 0 && dy === 0) return; if (!this.hasDragged) { this.hasDragged = true; this.element.classList.add('is-dragging'); this.onDrag?.(); } const r = this.element.getBoundingClientRect(); this.setView(dx / r.width, -dy / r.height); this.lastX = event.clientX; this.lastY = event.clientY; };
private end = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; this.activeId = null; this.hasDragged = false; this.element.classList.remove('is-dragging'); if (this.element.hasPointerCapture(event.pointerId)) this.element.releasePointerCapture(event.pointerId); };
dispose(): void { this.element.removeEventListener('pointerdown', this.down); this.element.removeEventListener('pointermove', this.move); this.element.removeEventListener('pointerup', this.end); this.element.removeEventListener('pointercancel', this.end); }
private draggingSubject = false;
private suppressSinglePointer = false;
private endSubject(cancelled: boolean): void {
if (!this.draggingSubject) return;
this.draggingSubject = false;
this.zoom?.subjectDrag?.end(cancelled);
}
constructor(
private readonly element: HTMLElement,
private readonly setView: (x: number, y: number) => void,
private readonly onDrag?: () => void,
private readonly zoom?: ZoomGestureOptions,
) {
element.addEventListener('pointerdown', this.down);
element.addEventListener('pointermove', this.move);
element.addEventListener('pointerup', this.end);
element.addEventListener('pointercancel', this.end);
element.addEventListener('lostpointercapture', this.end);
element.addEventListener('wheel', this.wheel, { passive: false });
}
get isInteracting(): boolean { return this.pointers.size > 0; }
private syncGesture(): void { this.zoom?.onGestureChange?.(this.isInteracting, this.pointers.size === 2); }
private isEnabled(): boolean {
if (this.zoom?.isEnabled?.() !== false) return true;
this.cancelGesture();
return false;
}
private distance(): number {
const [a, b] = this.pointers.values();
return a && b ? Math.hypot(b.x - a.x, b.y - a.y) : 0;
}
private midpoint(): SceneAnchor {
const [a, b] = this.pointers.values();
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
private anchor(point: SceneAnchor): SceneAnchor | null {
const rect = this.zoom?.getBounds ? this.zoom.getBounds(point) : this.element.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0 || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
// Captured fingers may leave the canvas; the projection controller bounds the crop.
return { x: (point.x - rect.left) / rect.width * 2 - 1, y: 1 - (point.y - rect.top) / rect.height * 2 };
}
private markDragged(): void {
if (this.hasDragged) return;
this.hasDragged = true;
this.element.classList.add('is-dragging');
this.onDrag?.();
}
private zoomBy(factor: number, from: SceneAnchor, to: SceneAnchor = from): boolean {
if (!this.zoom || !Number.isFinite(factor) || factor <= 0) return false;
const current = this.zoom.getZoom();
if (!Number.isFinite(current)) return false;
const next = Math.max(1, Math.min(3, current * factor));
if (next === current && from.x === to.x && from.y === to.y) return false;
this.zoom.setZoom(next, from, to);
return true;
}
private down = (event: PointerEvent): void => {
if(event.pointerType!=='touch'&&event.button!==0)return;
if (!this.isEnabled() || this.pointers.has(event.pointerId) || this.pointers.size >= 2) return;
if (this.zoom?.getBounds) {
const rect = this.zoom.getBounds({ x: event.clientX, y: event.clientY });
if (!rect || event.clientX < rect.left || event.clientX > rect.left + rect.width || event.clientY < rect.top || event.clientY > rect.top + rect.height) return;
}
// Only a pair of touch contacts pinches; extra contacts cannot replace it.
const first = this.pointers.values().next().value;
if (first && (!this.zoom || first.type !== 'touch' || event.pointerType !== 'touch')) return;
this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY, type: event.pointerType });
try {
this.element.setPointerCapture(event.pointerId);
} catch {
// The browser may have already canceled this pointer before capture.
this.pointers.delete(event.pointerId);
}
if (this.pointers.size === 1 && this.pointers.has(event.pointerId)) {
this.suppressSinglePointer = false;
this.draggingSubject = this.zoom?.subjectDrag?.begin({ x: event.clientX, y: event.clientY }) ?? false;
} else if (this.pointers.size === 2 && this.draggingSubject) {
this.endSubject(true);
this.suppressSinglePointer = true;
}
this.pinchDistance = this.distance();
this.syncGesture();
};
private move = (event: PointerEvent): void => {
if (!this.isEnabled()) return;
const pointer = this.pointers.get(event.pointerId);
if (!pointer) return;
const oldMidpoint = this.pointers.size === 2 ? this.midpoint() : null;
const dx = event.clientX - pointer.x;
const dy = event.clientY - pointer.y;
pointer.x = event.clientX;
pointer.y = event.clientY;
if (this.pointers.size === 2) {
const distance = this.distance();
const from = oldMidpoint && this.anchor(oldMidpoint);
const to = this.anchor(this.midpoint());
if (from && to && this.pinchDistance > 0 && distance > 0 && this.zoomBy(distance / this.pinchDistance, from, to)) this.markDragged();
// Rebase even at zoom limits, so reversing the pinch responds immediately.
this.pinchDistance = distance;
return;
}
if (this.suppressSinglePointer || (dx === 0 && dy === 0)) return;
if (this.draggingSubject) {
this.markDragged();
this.zoom?.subjectDrag?.move({ x: event.clientX, y: event.clientY });
return;
}
const rect = this.zoom?.getBounds ? this.zoom.getBounds({ x: event.clientX, y: event.clientY }) : this.element.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return;
this.markDragged();
this.setView(dx / rect.width, -dy / rect.height);
};
private wheel = (event: WheelEvent): void => {
if (!this.isEnabled() || !this.zoom || event.ctrlKey || !Number.isFinite(event.deltaY) || event.deltaY === 0) return;
const anchor = this.anchor({ x: event.clientX, y: event.clientY });
if (!anchor) return;
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? this.element.getBoundingClientRect().height : 1;
const exponent = Math.max(-20, Math.min(20, -event.deltaY * unit * 0.0015));
event.preventDefault();
if (this.zoomBy(Math.exp(exponent), anchor)) this.onDrag?.();
};
private releaseCapture(pointerId: number): void {
try {
if (this.element.hasPointerCapture(pointerId)) this.element.releasePointerCapture(pointerId);
} catch {
// Capture can be lost automatically on cancel or element removal.
}
}
private end = (event: PointerEvent): void => {
if (!this.pointers.delete(event.pointerId)) return;
this.endSubject(event.type !== 'pointerup');
this.pinchDistance = 0;
if (this.pointers.size === 0) {
this.hasDragged = false;
this.element.classList.remove('is-dragging');
}
// The remaining pointer already stores its latest position, avoiding a jump.
this.releaseCapture(event.pointerId);
this.syncGesture();
};
/** Call when opening an overlay, even if no subsequent pointer event arrives. */
cancelGesture(): void {
this.endSubject(true);
this.suppressSinglePointer = false;
const ids = [...this.pointers.keys()];
this.pointers.clear();
this.pinchDistance = 0;
this.hasDragged = false;
this.element.classList.remove('is-dragging');
ids.forEach(id => this.releaseCapture(id));
if (ids.length) this.syncGesture();
}
dispose(): void {
this.element.removeEventListener('pointerdown', this.down);
this.element.removeEventListener('pointermove', this.move);
this.element.removeEventListener('pointerup', this.end);
this.element.removeEventListener('pointercancel', this.end);
this.element.removeEventListener('lostpointercapture', this.end);
this.element.removeEventListener('wheel', this.wheel);
this.cancelGesture();
}
}
+38
View File
@@ -0,0 +1,38 @@
import * as THREE from 'three';
/** Screen-space anchors in canvas NDC: left/bottom -1, right/top +1. */
export interface SceneAnchor { x: number; y: number }
const center: SceneAnchor = { x: 0, y: 0 };
/** Affine projection crop shared by the photo, glass and frame; never moves geometry. */
export class SceneZoomController {
value = 1;
readonly offset = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera) {}
setZoom(value: number, from: SceneAnchor = center, to: SceneAnchor = from): void {
if (![value, from.x, from.y, to.x, to.y].every(Number.isFinite)) return;
const next = THREE.MathUtils.clamp(value, 1, 3);
const ratio = next / this.value;
const limit = next - 1;
// screen = zoom * originalScreen + offset. Keep the old anchor under the new one.
// Bounds keep the crop within the original 1x viewport and recenter exactly at 1x.
this.offset.set(
THREE.MathUtils.clamp(to.x - ratio * (from.x - this.offset.x), -limit, limit),
THREE.MathUtils.clamp(to.y - ratio * (from.y - this.offset.y), -limit, limit),
);
if (next === 1) this.offset.set(0, 0);
this.value = next;
this.updateProjection();
}
/** Reapply after aspect changes, including the inverse used by unprojection. */
updateProjection(): void {
this.camera.zoom = this.value;
this.camera.updateProjectionMatrix();
this.camera.projectionMatrix.elements[8] -= this.offset.x;
this.camera.projectionMatrix.elements[9] -= this.offset.y;
this.camera.projectionMatrixInverse.copy(this.camera.projectionMatrix).invert();
}
}
+44
View File
@@ -0,0 +1,44 @@
/** One rendered mechanical position, advanced only by the scene's existing RAF. */
export class WindowSlideController {
private position = 0;
private motion: { from: number; to: number; startedAt: number } | null = null;
readonly durationMs = 350;
get value(): number { return this.position; }
get target(): number { return this.motion?.to ?? this.position; }
get isAnimating(): boolean { return this.motion !== null; }
/** Direct manipulation is immediate and takes ownership from any animation. */
set(value: number): void {
if (!Number.isFinite(value)) return;
this.cancel();
this.position = Math.max(0, Math.min(1, value));
}
moveTo(value: number, now: number, reducedMotion = false): void {
if (!Number.isFinite(value) || !Number.isFinite(now)) return;
const to = Math.max(0, Math.min(1, value));
if (reducedMotion || to === this.position) { this.set(to); return; }
if (this.motion?.to === to) return;
// Never sample an unrendered timestamp on reversal: start exactly where
// the sash, glass, shader and handle were last presented together.
this.motion = { from: this.position, to, startedAt: now };
}
update(now: number, enabled = true, reducedMotion = false): number {
if (!enabled) this.cancel();
const motion = this.motion;
if (!motion || !Number.isFinite(now)) return this.position;
if (reducedMotion || now >= motion.startedAt + this.durationMs) {
this.set(motion.to);
return this.position;
}
const t = Math.max(0, (now - motion.startedAt) / this.durationMs);
const eased = t * t * (3 - 2 * t);
this.position = motion.from + (motion.to - motion.from) * eased;
return this.position;
}
/** Hold the last rendered value; disabling never resumes an old trajectory. */
cancel(): void { this.motion = null; }
}
+46
View File
@@ -0,0 +1,46 @@
import { createPhotoSnapshot } from './PhotoSnapshot';
export type WorkingPhotoImage = { image: HTMLImageElement; url: string; dispose(): void };
/** The photo renderer, inspection and journey preview share one bounded DOM
* image. PNG is a lossless adapter for those existing <img> consumers, not a
* target byte-size compressor; transparent input keeps its alpha. */
export async function createWorkingPhotoImage(file: File, id: number, signal: AbortSignal): Promise<WorkingPhotoImage> {
const snapshot = await createPhotoSnapshot(file, id, signal);
const canvas = document.createElement('canvas');
canvas.width = snapshot.width; canvas.height = snapshot.height;
let url = '';
const image = new Image();
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
image.onload = image.onerror = null;
image.removeAttribute('src');
if (url) URL.revokeObjectURL(url);
};
try {
signal.throwIfAborted();
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 준비할 수 없어요.');
context.drawImage(snapshot.bitmap, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error('작업용 사진을 만들지 못했어요.')), 'image/png'));
signal.throwIfAborted();
url = URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
const cleanup = () => { image.onload = image.onerror = null; signal.removeEventListener('abort', abort); };
const abort = () => { cleanup(); reject(new DOMException('사진 준비를 취소했어요.', 'AbortError')); };
image.onload = () => { cleanup(); resolve(); };
image.onerror = () => { cleanup(); reject(new Error('작업용 사진을 읽지 못했어요.')); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort(); else image.src = url;
});
signal.throwIfAborted();
return { image, url, dispose };
} catch (error) {
dispose(); throw error;
} finally {
snapshot.bitmap.close();
canvas.width = canvas.height = 1;
}
}