├── public ├── CNAME └── vite.svg ├── README.md ├── src ├── vite-env.d.ts ├── assets │ └── fonts │ │ ├── DepartureMono-Regular.otf │ │ ├── DepartureMono-Regular.woff │ │ └── DepartureMono-Regular.woff2 ├── consts.tsx ├── index.tsx ├── types.tsx ├── index.css ├── utils.tsx ├── atoms.tsx ├── useStream.ts ├── useDevices.ts └── App.tsx ├── tsconfig.json ├── plan.md ├── vite.config.ts ├── tailwind.config.js ├── .gitignore ├── index.html ├── tsconfig.node.json ├── .gcloudignore ├── tsconfig.app.json ├── eslint.config.js └── package.json /public/CNAME: -------------------------------------------------------------------------------- 1 | ghost.constraint.systems 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ghost 2 | 3 | `npm install` 4 | `npm run dev` 5 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/assets/fonts/DepartureMono-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constraint-systems/ghost/main/src/assets/fonts/DepartureMono-Regular.otf -------------------------------------------------------------------------------- /src/assets/fonts/DepartureMono-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constraint-systems/ghost/main/src/assets/fonts/DepartureMono-Regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/DepartureMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/constraint-systems/ghost/main/src/assets/fonts/DepartureMono-Regular.woff2 -------------------------------------------------------------------------------- /src/consts.tsx: -------------------------------------------------------------------------------- 1 | export const idealResolution = { 2 | width: 3840, 3 | height: 2160, 4 | }; 5 | export const rows = 4; 6 | export const cols = 4; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /plan.md: -------------------------------------------------------------------------------- 1 | - [x] get rid of rotation 2 | - [ ] make selectors dropdowns 3 | - [ ] stamping takes whole image and crops it - also preserves flips on block 4 | - [ ] crops shows some kind of active indicator 5 | - [ ] +camera +image in top right 6 | [hmmm] 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tailwindcss()], 8 | }); 9 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from "./App" 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | 12 | 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | safelist: [ 5 | 'bg-yellow-500', 6 | 'border-yellow-500', 7 | 'bg-orange-500', 8 | 'border-orange-500', 9 | ], 10 | theme: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .vite/ 16 | .env 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ghost 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Node.js dependencies: 17 | node_modules/ -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/types.tsx: -------------------------------------------------------------------------------- 1 | export type SizeType = { 2 | width: number; 3 | height: number; 4 | }; 5 | 6 | export type ModeType = "multiply" | "difference" | "screen"; 7 | 8 | export type StateRefType = { 9 | devices: MediaDeviceInfo[]; 10 | selectedDevice: string | null; 11 | videoSize: SizeType | null; 12 | mode: ModeType; 13 | startTime: Date | null; 14 | currentTime: Date | null; 15 | flippedHorizontally: boolean; 16 | flippedVertically: boolean; 17 | showInfoModal: boolean; 18 | isCapturing: boolean; 19 | baseCanvas: HTMLCanvasElement; 20 | baseCtx: CanvasRenderingContext2D; 21 | nowCanvas: HTMLCanvasElement; 22 | nowCtx: CanvasRenderingContext2D; 23 | downloadCanvas: HTMLCanvasElement; 24 | downloadCtx: CanvasRenderingContext2D; 25 | zoom: boolean; 26 | showDownloadModal: boolean; 27 | } 28 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | color-scheme: dark; 5 | } 6 | 7 | @font-face { 8 | font-family: "DepartureMono"; 9 | src: 10 | url("./assets/fonts/DepartureMono-Regular.woff2") format("woff2"), 11 | url("./assets/fonts/DepartureMono-Regular.woff") format("woff"); 12 | font-weight: normal; 13 | font-style: normal; 14 | } 15 | 16 | html, 17 | body, 18 | #root { 19 | height: 100dvh; 20 | font-size: 15px; 21 | line-height: 1.8; 22 | overflow: hidden; 23 | font-family: "DepartureMono", monospace; 24 | } 25 | 26 | body { 27 | background-color: theme("colors.neutral.950"); 28 | color: theme("colors.neutral.200"); 29 | } 30 | 31 | input, 32 | textarea { 33 | background-color: theme("colors.neutral.800"); 34 | color: theme("colors.neutral.200"); 35 | } 36 | 37 | button:focus, input:focus, textarea:focus { 38 | outline: none; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | // load image as promise 4 | export function loadImage(src: string): Promise { 5 | return new Promise((resolve, reject) => { 6 | const img = new Image(); 7 | img.onload = () => resolve(img); 8 | img.onerror = reject; 9 | img.src = src; 10 | }); 11 | } 12 | 13 | export function makeZIndex() { 14 | return Math.round((Date.now() - 1729536285367) / 100); 15 | } 16 | 17 | export function rotateAroundCenter( 18 | x: number, 19 | y: number, 20 | cx: number, 21 | cy: number, 22 | angle: number, 23 | ) { 24 | return [ 25 | (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, 26 | (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, 27 | ]; 28 | } 29 | 30 | export function randomHexColor() { 31 | return `#${Math.floor(Math.random() * 0xffffff) 32 | .toString(16) 33 | .padStart(6, "0")}`; 34 | } 35 | 36 | export function formatDate(date: Date) { 37 | // Format as 4:30:24 PM 38 | return date.toLocaleTimeString([], { 39 | hour: "2-digit", 40 | minute: "2-digit", 41 | second: "2-digit", 42 | hour12: true, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "deploy": "npm run build && gh-pages -d dist" 12 | }, 13 | "dependencies": { 14 | "@google/genai": "^0.7.0", 15 | "@mediapipe/tasks-vision": "^0.10.0", 16 | "@tailwindcss/vite": "^4.1.11", 17 | "@types/uuid": "^10.0.0", 18 | "@use-gesture/react": "^10.3.1", 19 | "dotenv": "^16.4.7", 20 | "gh-pages": "^6.3.0", 21 | "jotai": "^2.10.0", 22 | "lucide-react": "^0.451.0", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "tailwind": "^4.0.0", 26 | "tailwindcss": "^4.1.11", 27 | "uuid": "^10.0.0" 28 | }, 29 | "devDependencies": { 30 | "@eslint/js": "^9.11.1", 31 | "@types/react": "^18.3.10", 32 | "@types/react-dom": "^18.3.0", 33 | "@vitejs/plugin-react": "^4.3.2", 34 | "autoprefixer": "^10.4.20", 35 | "eslint": "^9.11.1", 36 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 37 | "eslint-plugin-react-refresh": "^0.4.12", 38 | "globals": "^15.9.0", 39 | "postcss": "^8.4.47", 40 | "typescript": "^5.5.3", 41 | "typescript-eslint": "^8.7.0", 42 | "vite": "^5.4.18" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/atoms.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import { ModeType, StateRefType } from "./types"; 3 | 4 | export const devicesAtom = atom([]); 5 | export const selectedDeviceAtom = atom(null); 6 | export const videoSizeAtom = atom<{ width: number; height: number } | null>( 7 | null, 8 | ); 9 | export const modeAtom = atom("difference"); 10 | export const startTimeAtom = atom(null); 11 | export const currentTimeAtom = atom(null); 12 | export const flippedHorizontallyAtom = atom(true); 13 | export const flippedVerticallyAtom = atom(false); 14 | export const showInfoModalAtom = atom(false); 15 | export const zoomAtom = atom(true); 16 | export const showDownloadModalAtom = atom(false); 17 | 18 | const downloadCanvas = document.createElement("canvas"); 19 | const downloadCtx = downloadCanvas.getContext("2d")!; 20 | document.body.appendChild(downloadCanvas); 21 | 22 | const baseCanvas = document.createElement("canvas"); 23 | const baseCtx = baseCanvas.getContext("2d")!; 24 | document.body.appendChild(baseCanvas); 25 | 26 | const nowCanvas = document.createElement("canvas"); 27 | const nowCtx = nowCanvas.getContext("2d")!; 28 | document.body.appendChild(nowCanvas); 29 | 30 | export const stateRef: StateRefType = { 31 | devices: [], 32 | selectedDevice: null, 33 | videoSize: null, 34 | mode: "difference" as ModeType, 35 | startTime: null, 36 | currentTime: null, 37 | flippedHorizontally: true, 38 | flippedVertically: false, 39 | showInfoModal: false, 40 | isCapturing: false, 41 | baseCanvas, 42 | baseCtx, 43 | nowCanvas, 44 | nowCtx, 45 | downloadCanvas, 46 | downloadCtx, 47 | zoom: true, 48 | showDownloadModal: false, 49 | }; 50 | -------------------------------------------------------------------------------- /src/useStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useAtom } from "jotai"; 3 | import { activeStreamsAtom, BlockIdsAtom, BlockMapAtom } from "./atoms"; 4 | 5 | export function useStream() { 6 | const [activeStreams, setActiveStreams] = useAtom(activeStreamsAtom); 7 | 8 | useEffect(() => { 9 | const streamKeys = Object.keys(activeStreams); 10 | for (const key of streamKeys) { 11 | const activeStream = activeStreams[key]; 12 | if (!activeStream) continue; 13 | if (!activeStream.refs) 14 | activeStream.refs = { 15 | video: null, 16 | }; 17 | if (activeStream.stream && !activeStream.refs?.video) { 18 | activeStream.refs.video = document.createElement("video"); 19 | activeStream.refs.video.style.position = "absolute"; 20 | activeStream.refs.video.style.left = "0"; 21 | activeStream.refs.video.style.top = "0"; 22 | activeStream.refs.video.style.opacity = "0"; 23 | activeStream.refs.video.style.pointerEvents = "none"; 24 | activeStream.refs.video.autoplay = true; 25 | activeStream.refs.video.playsInline = true; 26 | activeStream.refs.video.muted = true; 27 | document.body.appendChild(activeStream.refs.video); 28 | activeStream.refs.video.onloadedmetadata = () => { 29 | const videoWidth = activeStream.refs.video!.videoWidth; 30 | const videoHeight = activeStream.refs.video!.videoHeight; 31 | setActiveStreams((prev) => ({ 32 | ...prev, 33 | [key]: { 34 | ...prev[key], 35 | videoSize: { width: videoWidth, height: videoHeight }, 36 | }, 37 | })); 38 | }; 39 | activeStream.refs.video.srcObject = activeStream.stream; 40 | } 41 | } 42 | }, [activeStreams]); 43 | } 44 | -------------------------------------------------------------------------------- /src/useDevices.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { idealResolution } from "./consts"; 3 | import { 4 | selectedDeviceAtom, 5 | devicesAtom, 6 | videoSizeAtom, 7 | stateRef, 8 | } from "./atoms"; 9 | import { useAtom } from "jotai"; 10 | 11 | const video = document.createElement("video"); 12 | video.style.position = "absolute"; 13 | video.style.left = "0"; 14 | video.style.top = "0"; 15 | video.style.opacity = "0"; 16 | video.style.pointerEvents = "none"; 17 | video.autoplay = true; 18 | video.playsInline = true; 19 | video.muted = true; 20 | video.id = "webcam-video"; 21 | document.body.appendChild(video); 22 | 23 | export function useDevices() { 24 | const [devices, setDevices] = useAtom(devicesAtom); 25 | const [selectedDevice, setSelectedDevice] = useAtom(selectedDeviceAtom); 26 | const [videoSize, setVideoSize] = useAtom(videoSizeAtom); 27 | const streamRef = useRef(null); 28 | 29 | useEffect(() => { 30 | async function handleDeviceChange() { 31 | if (!selectedDevice) return; 32 | if (streamRef.current) { 33 | video.srcObject = null; 34 | streamRef.current.getTracks().forEach((track) => track.stop()); 35 | // setVideoSize(null); 36 | } 37 | streamRef.current = await navigator.mediaDevices.getUserMedia({ 38 | video: { 39 | deviceId: { exact: selectedDevice }, 40 | width: { ideal: idealResolution.width }, 41 | }, 42 | }); 43 | video.srcObject = streamRef.current; 44 | video.onloadedmetadata = () => { 45 | if (video.videoWidth && video.videoHeight) { 46 | if ( 47 | !stateRef.videoSize || 48 | stateRef.videoSize.width !== video.videoWidth || 49 | stateRef.videoSize.height !== video.videoHeight 50 | ) { 51 | setVideoSize({ 52 | width: video.videoWidth, 53 | height: video.videoHeight, 54 | }); 55 | } 56 | } 57 | }; 58 | } 59 | handleDeviceChange(); 60 | return () => { 61 | if (streamRef.current) { 62 | video.srcObject = null; 63 | streamRef.current.getTracks().forEach((track) => track.stop()); 64 | streamRef.current = null; 65 | } 66 | }; 67 | }, [selectedDevice]); 68 | 69 | useEffect(() => { 70 | const getCameras = async () => { 71 | try { 72 | // Trigger the browser to ask for permission to use the camera 73 | await navigator.mediaDevices.getUserMedia({ 74 | video: { 75 | width: { ideal: idealResolution.width }, 76 | }, 77 | }); 78 | const devices = await navigator.mediaDevices.enumerateDevices(); 79 | let videoDevices = devices.filter( 80 | (device) => device.kind === "videoinput", 81 | ); 82 | 83 | setDevices(videoDevices); 84 | 85 | if (videoDevices.length > 0) { 86 | const storageCheck = localStorage.getItem("selectedDevice"); 87 | if (storageCheck) { 88 | if (videoDevices.some((d) => d.deviceId === storageCheck)) { 89 | // if the stored device is still available, use it 90 | setSelectedDevice(storageCheck); 91 | } else { 92 | setSelectedDevice(videoDevices[0].deviceId); 93 | } 94 | } else { 95 | // do not store - only when the user selects a device 96 | const initialDeviceId = videoDevices[0].deviceId; 97 | setSelectedDevice(initialDeviceId); 98 | } 99 | } 100 | } catch (e) { 101 | console.error(e); 102 | } 103 | }; 104 | getCameras(); 105 | function handleDeviceChange() { 106 | getCameras(); 107 | } 108 | navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); 109 | return () => { 110 | navigator.mediaDevices.removeEventListener( 111 | "devicechange", 112 | handleDeviceChange, 113 | ); 114 | }; 115 | }, []); 116 | 117 | return { 118 | devices, 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { 3 | currentTimeAtom, 4 | devicesAtom, 5 | flippedHorizontallyAtom, 6 | flippedVerticallyAtom, 7 | modeAtom, 8 | selectedDeviceAtom, 9 | showDownloadModalAtom, 10 | showInfoModalAtom, 11 | startTimeAtom, 12 | videoSizeAtom, 13 | zoomAtom, 14 | } from "./atoms"; 15 | import { useEffect, useRef } from "react"; 16 | import { useDevices } from "./useDevices"; 17 | import { ModeType } from "./types"; 18 | import { formatDate } from "./utils"; 19 | import { stateRef } from "./atoms"; 20 | 21 | export function App() { 22 | useDevices(); 23 | useKeyboard(); 24 | useRefUpdater(); 25 | const [zoom] = useAtom(zoomAtom); 26 | const [showInfoModal] = useAtom(showInfoModalAtom); 27 | const [showDownloadModal] = useAtom(showDownloadModalAtom); 28 | 29 | return ( 30 |
31 | 32 |
38 | 39 |
40 |
41 | 42 | 43 |
44 | {showInfoModal && } 45 | {showDownloadModal && } 46 |
47 | ); 48 | } 49 | 50 | export default App; 51 | 52 | function Timestamps() { 53 | const [startTime] = useAtom(startTimeAtom); 54 | const [currentTime, setCurrentTime] = useAtom(currentTimeAtom); 55 | 56 | useEffect(() => { 57 | const interval = setInterval(() => { 58 | setCurrentTime(new Date()); 59 | }, 1000); 60 | return () => clearInterval(interval); 61 | }, []); 62 | 63 | return ( 64 |
65 | {startTime && currentTime && ( 66 | <> 67 |
{startTime ? formatDate(startTime) : null}
68 |
69 |
{currentTime ? formatDate(currentTime) : null}
70 | 71 | )} 72 |
73 | ); 74 | } 75 | 76 | function TopBar() { 77 | const [devices] = useAtom(devicesAtom); 78 | const [zoom, setZoom] = useAtom(zoomAtom); 79 | const [flippedHorizontally, setFlippedHorizontally] = useAtom( 80 | flippedHorizontallyAtom, 81 | ); 82 | const [flippedVertically, setFlippedVertically] = useAtom( 83 | flippedVerticallyAtom, 84 | ); 85 | const [, setShowInfoModal] = useAtom(showInfoModalAtom); 86 | 87 | return ( 88 |
89 |
90 | {devices.length === 1 ? ( 91 |
95 |
{devices[0].label.split("(")[0] || "Camera"}
96 |
97 | ) : ( 98 | 99 | )} 100 |
101 |
102 | 110 | 111 | 119 | 127 |
128 | 136 |
137 | ); 138 | } 139 | 140 | function DeviceSelector() { 141 | const [devices] = useAtom(devicesAtom); 142 | const [selectedDevice, setSelectedDevice] = useAtom(selectedDeviceAtom); 143 | 144 | return ( 145 | 163 | ); 164 | } 165 | 166 | function useDrawBase() { 167 | const [, setStartTime] = useAtom(startTimeAtom); 168 | 169 | return function drawBase() { 170 | const baseCtx = stateRef.baseCtx; 171 | const videoSize = stateRef.videoSize; 172 | const $video = document.getElementById("webcam-video") as HTMLVideoElement; 173 | if ($video && $video.srcObject && videoSize) { 174 | setStartTime(new Date()); 175 | 176 | if (stateRef.flippedHorizontally || stateRef.flippedVertically) { 177 | baseCtx.save(); 178 | } 179 | if (stateRef.flippedHorizontally && stateRef.flippedVertically) { 180 | baseCtx.setTransform(-1, 0, 0, -1, videoSize.width, videoSize.height); 181 | } else if (stateRef.flippedHorizontally) { 182 | baseCtx.setTransform(-1, 0, 0, 1, videoSize.width, 0); 183 | } else if (stateRef.flippedVertically) { 184 | baseCtx.setTransform(1, 0, 0, -1, 0, videoSize.height); 185 | } 186 | baseCtx.drawImage($video, 0, 0, videoSize.width, videoSize.height); 187 | if (stateRef.flippedHorizontally || stateRef.flippedVertically) { 188 | baseCtx.restore(); 189 | } 190 | } 191 | }; 192 | } 193 | 194 | function useCaptureDownload() { 195 | return function captureDownload() { 196 | // stateRef.isCapturing = true; 197 | // const link = document.createElement("a"); 198 | const renderCanvas = document.getElementById( 199 | "render-canvas", 200 | ) as HTMLCanvasElement; 201 | if (!renderCanvas) return; 202 | stateRef.downloadCtx.drawImage(renderCanvas, 0, 0); 203 | // link.download = `ghost-${new Date().toISOString()}.png`; 204 | // link.href = stateRef.downloadCanvas.toDataURL("image/jpg"); 205 | // link.click(); 206 | // setTimeout(() => { 207 | // stateRef.isCapturing = false; 208 | // }, 1000); // Allow some time for the download to complete 209 | }; 210 | } 211 | 212 | function Toolbar() { 213 | const drawBase = useDrawBase(); 214 | const captureDownload = useCaptureDownload(); 215 | const [, setShowDownloadModal] = useAtom(showDownloadModalAtom); 216 | 217 | return ( 218 |
219 | 227 | 228 | 237 |
238 | ); 239 | } 240 | 241 | function ModeChooser() { 242 | const [mode, setMode] = useAtom(modeAtom); 243 | 244 | return ( 245 |
246 | {[ 247 | ["multiply", "M"], 248 | ["difference", "D"], 249 | ["screen", "S"], 250 | ].map(([itemMode, itemLabel]) => ( 251 | 258 | ))} 259 |
260 | ); 261 | } 262 | 263 | function Canvas() { 264 | const [videoSize] = useAtom(videoSizeAtom); 265 | const [mode] = useAtom(modeAtom); 266 | const [flippedHorizontally] = useAtom(flippedHorizontallyAtom); 267 | const [flippedVertically] = useAtom(flippedVerticallyAtom); 268 | const [zoom] = useAtom(zoomAtom); 269 | const renderCanvasRef = useRef(null); 270 | const drawBase = useDrawBase(); 271 | 272 | const modeRef = useRef(mode); 273 | modeRef.current = mode; 274 | const flippedHorizontallyRef = useRef(flippedHorizontally); 275 | flippedHorizontallyRef.current = flippedHorizontally; 276 | const flippedVerticallyRef = useRef(flippedVertically); 277 | flippedVerticallyRef.current = flippedVertically; 278 | 279 | useEffect(() => { 280 | const renderCanvas = renderCanvasRef.current; 281 | if (!renderCanvas || !videoSize) return; 282 | 283 | const baseCanvas = stateRef.baseCanvas; 284 | const nowCanvas = stateRef.nowCanvas; 285 | const downloadCanvas = stateRef.downloadCanvas; 286 | const nowCtx = stateRef.nowCtx; 287 | 288 | baseCanvas.width = videoSize.width; 289 | baseCanvas.height = videoSize.height; 290 | nowCanvas.width = videoSize.width; 291 | nowCanvas.height = videoSize.height; 292 | downloadCanvas.width = videoSize.width; 293 | downloadCanvas.height = videoSize.height; 294 | renderCanvas.width = videoSize.width; 295 | renderCanvas.height = videoSize.height; 296 | 297 | const ctx = renderCanvas.getContext("2d"); 298 | 299 | const $video = document.getElementById("webcam-video") as HTMLVideoElement; 300 | 301 | setTimeout(() => { 302 | drawBase(); 303 | }, 0); 304 | 305 | function drawVideo() { 306 | if (stateRef.isCapturing) { 307 | requestAnimationFrame(drawVideo); 308 | return; 309 | } 310 | 311 | if (flippedHorizontallyRef.current || flippedVerticallyRef.current) { 312 | nowCtx.save(); 313 | } 314 | if (flippedHorizontallyRef.current && flippedVerticallyRef.current) { 315 | nowCtx.setTransform(-1, 0, 0, -1, videoSize!.width, videoSize!.height); 316 | } else if (flippedHorizontallyRef.current) { 317 | nowCtx.setTransform(-1, 0, 0, 1, videoSize!.width, 0); 318 | } else if (flippedVerticallyRef.current) { 319 | nowCtx.setTransform(1, 0, 0, -1, 0, videoSize!.height); 320 | } 321 | nowCtx!.drawImage($video, 0, 0, videoSize!.width, videoSize!.height); 322 | if (flippedHorizontallyRef.current || flippedVerticallyRef.current) { 323 | nowCtx.restore(); 324 | } 325 | 326 | ctx!.globalCompositeOperation = "source-over"; 327 | ctx!.drawImage(baseCanvas, 0, 0, videoSize!.width, videoSize!.height); 328 | ctx!.globalCompositeOperation = modeRef.current as ModeType; 329 | ctx!.drawImage(nowCanvas, 0, 0, videoSize!.width, videoSize!.height); 330 | requestAnimationFrame(drawVideo); 331 | } 332 | drawVideo(); 333 | }, [videoSize]); 334 | 335 | return videoSize ? ( 336 | 346 | ) : null; 347 | } 348 | 349 | function InfoModal() { 350 | const [, setShowInfoModal] = useAtom(showInfoModalAtom); 351 | 352 | return ( 353 |
354 |
355 |
356 |
ABOUT
357 | 365 |
366 |
367 | Ghost shows a live blend of{" "} 368 | your current camera{" "} 369 | and the start frame. 370 | Try the multiply,{" "} 371 | difference, and{" "} 372 | screen blends.{" "} 373 | Download the result. 374 |
375 |
376 | A{" "} 377 | 382 | Constraint Systems 383 | {" "} 384 | project. 385 |
386 |
387 | Use of difference for motion extration inspired by{" "} 388 | 393 | Posy's video 394 | 395 | . 396 |
397 |
398 |
399 | ); 400 | } 401 | 402 | function DownloadModal() { 403 | const [, setShowInfoModal] = useAtom(showInfoModalAtom); 404 | const [, setShowDownloadModal] = useAtom(showDownloadModalAtom); 405 | const previewCanvasRef = useRef(null); 406 | const [videoSize] = useAtom(videoSizeAtom); 407 | 408 | useEffect(() => { 409 | const previewCanvas = previewCanvasRef.current; 410 | if (!previewCanvas || !videoSize) return; 411 | 412 | const previewCtx = previewCanvas.getContext("2d"); 413 | previewCanvas.width = videoSize.width; 414 | previewCanvas.height = videoSize.height; 415 | 416 | previewCtx!.clearRect(0, 0, previewCanvas.width, previewCanvas.height); 417 | const downloadCanvas = stateRef.downloadCanvas; 418 | if (downloadCanvas) { 419 | previewCtx!.drawImage( 420 | downloadCanvas, 421 | 0, 422 | 0, 423 | videoSize.width, 424 | videoSize.height, 425 | ); 426 | } 427 | }, [videoSize]); 428 | 429 | return ( 430 |
{ 433 | if (e.target === e.currentTarget) { 434 | setShowInfoModal(false); 435 | setShowDownloadModal(false); 436 | } 437 | }} 438 | > 439 |
{ 442 | e.stopPropagation(); 443 | }} 444 | > 445 |
446 |
DOWNLOAD
447 | 455 |
456 | 463 |
464 | 472 | 484 |
485 |
486 |
487 | ); 488 | } 489 | 490 | function useKeyboard() { 491 | const [, setShowInfoModal] = useAtom(showInfoModalAtom); 492 | const [flippedHorizontally, setFlippedHorizontally] = useAtom( 493 | flippedHorizontallyAtom, 494 | ); 495 | const [flippedVertically, setFlippedVertically] = useAtom( 496 | flippedVerticallyAtom, 497 | ); 498 | const [devices] = useAtom(devicesAtom); 499 | const [selectedDevice, setSelectedDevice] = useAtom(selectedDeviceAtom); 500 | const [, setMode] = useAtom(modeAtom); 501 | const captureDownload = useCaptureDownload(); 502 | const [showDownloadModal, setShowDownloadModal] = useAtom( 503 | showDownloadModalAtom, 504 | ); 505 | const drawBase = useDrawBase(); 506 | 507 | useEffect(() => { 508 | function handleKeyDown(event: KeyboardEvent) { 509 | if (event.key === "i") { 510 | setShowInfoModal((prev) => !prev); 511 | } else if (event.key === "Escape") { 512 | setShowInfoModal(false); 513 | setShowDownloadModal(false); 514 | } else if (event.key === "h") { 515 | setFlippedHorizontally(!flippedHorizontally); 516 | } else if (event.key === "v") { 517 | setFlippedVertically(!flippedVertically); 518 | } else if (event.key === " ") { 519 | drawBase(); 520 | } else if (event.key === "Enter") { 521 | captureDownload(); 522 | if (showDownloadModal) { 523 | const link = document.createElement("a"); 524 | link.download = `ghost-${new Date().toISOString()}.png`; 525 | link.href = stateRef.downloadCanvas.toDataURL("image/png"); 526 | link.click(); 527 | setShowDownloadModal(false); 528 | } else { 529 | setShowDownloadModal((prev) => !prev); 530 | } 531 | } else if (event.key === "m") { 532 | setMode("multiply"); 533 | } else if (event.key === "d") { 534 | setMode("difference"); 535 | } else if (event.key === "s") { 536 | setMode("screen"); 537 | } else if (event.key === "c") { 538 | if (devices.length > 1) { 539 | const currentIndex = devices.findIndex( 540 | (device) => device.deviceId === selectedDevice, 541 | ); 542 | const nextIndex = (currentIndex + 1) % devices.length; 543 | const id = devices[nextIndex].deviceId; 544 | localStorage.setItem("selectedDevice", id); 545 | setSelectedDevice(id); 546 | } 547 | } 548 | } 549 | 550 | window.addEventListener("keydown", handleKeyDown); 551 | return () => window.removeEventListener("keydown", handleKeyDown); 552 | }, [ 553 | setShowInfoModal, 554 | flippedHorizontally, 555 | flippedVertically, 556 | showDownloadModal, 557 | devices, 558 | selectedDevice, 559 | setSelectedDevice, 560 | ]); 561 | } 562 | 563 | function useRefUpdater() { 564 | const [devices] = useAtom(devicesAtom); 565 | const [selectedDevice] = useAtom(selectedDeviceAtom); 566 | const [videoSize] = useAtom(videoSizeAtom); 567 | const [mode] = useAtom(modeAtom); 568 | const [startTime] = useAtom(startTimeAtom); 569 | const [currentTime] = useAtom(currentTimeAtom); 570 | const [flippedHorizontally] = useAtom(flippedHorizontallyAtom); 571 | const [flippedVertically] = useAtom(flippedVerticallyAtom); 572 | const [showInfoModal] = useAtom(showInfoModalAtom); 573 | const [zoom] = useAtom(zoomAtom); 574 | const [showDownloadModal] = useAtom(showDownloadModalAtom); 575 | 576 | useEffect(() => { 577 | stateRef.devices = devices; 578 | stateRef.selectedDevice = selectedDevice; 579 | stateRef.videoSize = videoSize; 580 | stateRef.mode = mode; 581 | stateRef.startTime = startTime; 582 | stateRef.currentTime = currentTime; 583 | stateRef.flippedHorizontally = flippedHorizontally; 584 | stateRef.flippedVertically = flippedVertically; 585 | stateRef.showInfoModal = showInfoModal; 586 | stateRef.zoom = zoom; 587 | stateRef.showDownloadModal = showDownloadModal; 588 | }, [ 589 | devices, 590 | selectedDevice, 591 | videoSize, 592 | mode, 593 | startTime, 594 | currentTime, 595 | flippedHorizontally, 596 | flippedVertically, 597 | showInfoModal, 598 | showDownloadModal, 599 | zoom, 600 | ]); 601 | } 602 | --------------------------------------------------------------------------------