├── 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 |
386 |
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 |
--------------------------------------------------------------------------------