diff --git a/bun.lock b/bun.lock index dde058a..e076714 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,6 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.102.1", "@tanstack/react-router": "^1.170.32", - "@tensorflow/tfjs": "^4.22.0", "axios": "^1.19.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -544,40 +543,18 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], - "@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="], - - "@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="], - - "@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="], - - "@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="], - - "@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="], - - "@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="], - - "@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="], - "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], - "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], - "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - - "@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="], - "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], - "@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="], - "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -622,8 +599,6 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="], - "@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -640,7 +615,7 @@ "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -682,7 +657,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -720,8 +695,6 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], @@ -884,8 +857,6 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1034,8 +1005,6 @@ "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], - "long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="], @@ -1074,8 +1043,6 @@ "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], - "node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="], - "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1170,8 +1137,6 @@ "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], - "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1194,8 +1159,6 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1204,8 +1167,6 @@ "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="], - "seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -1248,8 +1209,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1258,8 +1217,6 @@ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1268,7 +1225,7 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], @@ -1288,8 +1245,6 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -1338,14 +1293,10 @@ "web-tree-sitter": ["web-tree-sitter@0.26.13", "", {}, "sha512-5bUZ7vbQ1kcondet96wzP974+JfCZDeQ7bTpacICm2nnvHpa5cO0ByRsoMcAhUP+743vpkb4m0BFlVSm+Ye9VA=="], - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="], - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="], @@ -1394,8 +1345,6 @@ "@feature-sliced/filesystem/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], @@ -1418,10 +1367,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tensorflow/tfjs/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], - - "@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], - "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -1450,10 +1395,6 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1464,8 +1405,6 @@ "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1534,12 +1473,6 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "@tensorflow/tfjs/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], - - "@tensorflow/tfjs/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "@tensorflow/tfjs/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1566,16 +1499,6 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@tensorflow/tfjs/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@tensorflow/tfjs/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "@tensorflow/tfjs/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@tensorflow/tfjs/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@tensorflow/tfjs/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index ad8cc05..7c08878 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "^5.102.1", "@tanstack/react-router": "^1.170.32", - "@tensorflow/tfjs": "^4.22.0", "axios": "^1.19.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/public/ksl/model/NOTICE.md b/public/ksl/model/NOTICE.md deleted file mode 100644 index 473bddc..0000000 --- a/public/ksl/model/NOTICE.md +++ /dev/null @@ -1,14 +0,0 @@ -# 모델 출처 - -`model.json` / weight shard는 [legatalee/Korean-Sign-Language-Translation](https://github.com/legatalee/Korean-Sign-Language-Translation)의 -`Recognition/model1/my_model.h5`(MIT License, Copyright (c) 2025 Jimin Lee)를 -`tensorflowjs_converter`로 TF.js Layers 포맷으로 변환한 결과물이다. - -- 원본 저장소: https://github.com/legatalee/Korean-Sign-Language-Translation -- 라이선스: MIT -- 입력 shape: `(30, 150)` — pose 6개 landmark(x,y,z,visibility) + 양손 각 21개 landmark(x,y,z) -- 출력: 12 클래스 (0=no-action, 1~11=오늘/날씨/좋다/맛있다/식사/감사/안녕/소개/나/만나다/반갑다) -- 변환 명령: `tensorflowjs_converter --input_format=keras my_model.h5 ` - -라벨 index 매핑은 `Recognition/model1/MP_Data/<라벨>/`의 실제 landmark 샘플을 원본 -h5 모델에 넣어 재검증했다 (`../fixtures/siksa.json` 참고). diff --git a/public/ksl/model/group1-shard1of1.bin b/public/ksl/model/group1-shard1of1.bin deleted file mode 100644 index 4e3c3c8..0000000 Binary files a/public/ksl/model/group1-shard1of1.bin and /dev/null differ diff --git a/public/ksl/model/model.json b/public/ksl/model/model.json deleted file mode 100644 index 39ef937..0000000 --- a/public/ksl/model/model.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "format": "layers-model", - "generatedBy": "keras v2.21.0", - "convertedBy": "TensorFlow.js Converter v3.18.0", - "modelTopology": { - "keras_version": "2.21.0", - "backend": "tensorflow", - "model_config": { - "class_name": "Sequential", - "config": { - "name": "sequential", - "layers": [ - { - "class_name": "InputLayer", - "config": { - "batch_input_shape": [null, 30, 150], - "dtype": "float32", - "sparse": false, - "ragged": false, - "name": "lstm_input", - "optional": false - } - }, - { - "class_name": "LSTM", - "config": { - "name": "lstm", - "trainable": true, - "dtype": "float32", - "batch_input_shape": [null, 30, 150], - "return_sequences": true, - "return_state": false, - "go_backwards": false, - "stateful": false, - "unroll": false, - "time_major": false, - "units": 64, - "activation": "relu", - "recurrent_activation": "sigmoid", - "use_bias": true, - "kernel_initializer": { - "module": "keras.initializers", - "class_name": "GlorotUniform", - "config": { "seed": null }, - "registered_name": null - }, - "recurrent_initializer": { - "module": "keras.initializers", - "class_name": "Orthogonal", - "config": { "gain": 1.0, "seed": null }, - "registered_name": null - }, - "bias_initializer": { - "module": "keras.initializers", - "class_name": "Zeros", - "config": {}, - "registered_name": null - }, - "unit_forget_bias": true, - "kernel_regularizer": null, - "recurrent_regularizer": null, - "bias_regularizer": null, - "activity_regularizer": null, - "kernel_constraint": null, - "recurrent_constraint": null, - "bias_constraint": null, - "dropout": 0.0, - "recurrent_dropout": 0.0, - "implementation": 2 - } - }, - { - "class_name": "LSTM", - "config": { - "name": "lstm_1", - "trainable": true, - "dtype": "float32", - "return_sequences": false, - "return_state": false, - "go_backwards": false, - "stateful": false, - "unroll": false, - "time_major": false, - "units": 64, - "activation": "relu", - "recurrent_activation": "sigmoid", - "use_bias": true, - "kernel_initializer": { - "module": "keras.initializers", - "class_name": "GlorotUniform", - "config": { "seed": null }, - "registered_name": null - }, - "recurrent_initializer": { - "module": "keras.initializers", - "class_name": "Orthogonal", - "config": { "gain": 1.0, "seed": null }, - "registered_name": null - }, - "bias_initializer": { - "module": "keras.initializers", - "class_name": "Zeros", - "config": {}, - "registered_name": null - }, - "unit_forget_bias": true, - "kernel_regularizer": null, - "recurrent_regularizer": null, - "bias_regularizer": null, - "activity_regularizer": null, - "kernel_constraint": null, - "recurrent_constraint": null, - "bias_constraint": null, - "dropout": 0.0, - "recurrent_dropout": 0.0, - "implementation": 2 - } - }, - { - "class_name": "Dropout", - "config": { - "name": "dropout", - "trainable": true, - "dtype": "float32", - "rate": 0.5, - "noise_shape": null, - "seed": null - } - }, - { - "class_name": "Dense", - "config": { - "name": "dense", - "trainable": true, - "dtype": "float32", - "units": 32, - "activation": "relu", - "use_bias": true, - "kernel_initializer": { - "module": "keras.initializers", - "class_name": "GlorotUniform", - "config": { "seed": null }, - "registered_name": null - }, - "bias_initializer": { - "module": "keras.initializers", - "class_name": "Zeros", - "config": {}, - "registered_name": null - }, - "kernel_regularizer": null, - "bias_regularizer": null, - "activity_regularizer": null, - "kernel_constraint": null, - "bias_constraint": null - } - }, - { - "class_name": "Dense", - "config": { - "name": "dense_1", - "trainable": true, - "dtype": "float32", - "units": 12, - "activation": "softmax", - "use_bias": true, - "kernel_initializer": { - "module": "keras.initializers", - "class_name": "GlorotUniform", - "config": { "seed": null }, - "registered_name": null - }, - "bias_initializer": { - "module": "keras.initializers", - "class_name": "Zeros", - "config": {}, - "registered_name": null - }, - "kernel_regularizer": null, - "bias_regularizer": null, - "activity_regularizer": null, - "kernel_constraint": null, - "bias_constraint": null - } - } - ] - } - } - }, - "weightsManifest": [ - { - "paths": ["group1-shard1of1.bin"], - "weights": [ - { "name": "dense/kernel", "shape": [64, 32], "dtype": "float32" }, - { "name": "dense/bias", "shape": [32], "dtype": "float32" }, - { "name": "dense_1/kernel", "shape": [32, 12], "dtype": "float32" }, - { "name": "dense_1/bias", "shape": [12], "dtype": "float32" }, - { "name": "lstm/lstm_cell/kernel", "shape": [150, 256], "dtype": "float32" }, - { "name": "lstm/lstm_cell/recurrent_kernel", "shape": [64, 256], "dtype": "float32" }, - { "name": "lstm/lstm_cell/bias", "shape": [256], "dtype": "float32" }, - { "name": "lstm_1/lstm_cell/kernel", "shape": [64, 256], "dtype": "float32" }, - { "name": "lstm_1/lstm_cell/recurrent_kernel", "shape": [64, 256], "dtype": "float32" }, - { "name": "lstm_1/lstm_cell/bias", "shape": [256], "dtype": "float32" } - ] - } - ] -} diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 320ca5d..3360fc0 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -14,7 +14,6 @@ import { Route as CallRouteImport } from './routes/call' import { Route as LobbyRouteImport } from './routes/lobby' import { Route as MyRouteImport } from './routes/my' import { Route as RecentRouteImport } from './routes/recent' -import { Route as TestCameraRouteImport } from './routes/test-camera' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -41,11 +40,6 @@ const RecentRoute = RecentRouteImport.update({ path: '/recent', getParentRoute: () => rootRouteImport, } as any) -const TestCameraRoute = TestCameraRouteImport.update({ - id: '/test-camera', - path: '/test-camera', - getParentRoute: () => rootRouteImport, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -53,7 +47,6 @@ export interface FileRoutesByFullPath { '/lobby': typeof LobbyRoute '/my': typeof MyRoute '/recent': typeof RecentRoute - '/test-camera': typeof TestCameraRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -61,7 +54,6 @@ export interface FileRoutesByTo { '/lobby': typeof LobbyRoute '/my': typeof MyRoute '/recent': typeof RecentRoute - '/test-camera': typeof TestCameraRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -70,14 +62,13 @@ export interface FileRoutesById { '/lobby': typeof LobbyRoute '/my': typeof MyRoute '/recent': typeof RecentRoute - '/test-camera': typeof TestCameraRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/call' | '/lobby' | '/my' | '/recent' | '/test-camera' + fullPaths: '/' | '/call' | '/lobby' | '/my' | '/recent' fileRoutesByTo: FileRoutesByTo - to: '/' | '/call' | '/lobby' | '/my' | '/recent' | '/test-camera' - id: '__root__' | '/' | '/call' | '/lobby' | '/my' | '/recent' | '/test-camera' + to: '/' | '/call' | '/lobby' | '/my' | '/recent' + id: '__root__' | '/' | '/call' | '/lobby' | '/my' | '/recent' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -86,7 +77,6 @@ export interface RootRouteChildren { LobbyRoute: typeof LobbyRoute MyRoute: typeof MyRoute RecentRoute: typeof RecentRoute - TestCameraRoute: typeof TestCameraRoute } declare module '@tanstack/react-router' { @@ -126,13 +116,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RecentRouteImport parentRoute: typeof rootRouteImport } - '/test-camera': { - id: '/test-camera' - path: '/test-camera' - fullPath: '/test-camera' - preLoaderRoute: typeof TestCameraRouteImport - parentRoute: typeof rootRouteImport - } } } @@ -142,7 +125,6 @@ const rootRouteChildren: RootRouteChildren = { LobbyRoute: LobbyRoute, MyRoute: MyRoute, RecentRoute: RecentRoute, - TestCameraRoute: TestCameraRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/app/routes/test-camera.tsx b/src/app/routes/test-camera.tsx deleted file mode 100644 index d85e13e..0000000 --- a/src/app/routes/test-camera.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { TestCameraPage } from "@/pages/test-camera"; - -export const Route = createFileRoute("/test-camera")({ - component: TestCameraPage, -}); diff --git a/src/features/sign-capture/model/dtw.ts b/src/features/sign-capture/model/dtw.ts index b4e47f1..2b1b7d4 100644 --- a/src/features/sign-capture/model/dtw.ts +++ b/src/features/sign-capture/model/dtw.ts @@ -1,26 +1,33 @@ -// v2: references are now stored body-relative (see normalizeFrame) instead of raw -// screen-space coordinates. v1 references would silently mismatch under the new -// normalization, so this bumps the key and effectively starts everyone fresh -- -// re-record any custom words after this ships. -const STORAGE_KEY = "naru-sign-references-v2"; +import { apiClient } from "@/shared/api"; + const MAX_SAMPLES_PER_WORD = 10; -// Starting point -- tune against real recordings. Distances are now body-relative -// (shoulder-width units), so this is unrelated to the old v1 threshold's scale. +// Starting point -- tune against real recordings. Distances are body-relative +// (shoulder-width units). export const DEFAULT_DTW_THRESHOLD = 0.6; type ReferenceStore = Record; -// Reference sequences never leave the browser: no server call, no upload. -function loadReferences(): ReferenceStore { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); - } catch { - return {}; - } +// Reference samples are global now (see backend PR: com.ssu.unithon.sign.domain.SignWordReference) +// -- there's no per-user auth in this app, so a word one signer records becomes recognizable +// for everyone. DTW matching runs client-side on every video frame (15-30x/second), so it +// cannot be a network call -- this in-memory cache is the thing actually queried per frame, +// refreshed from the server on load and kept in sync optimistically on save/delete. +let cache: ReferenceStore = {}; + +/** Fetches the full reference store from the server and refreshes the in-memory matching cache. Call once on mount; recognition uses the cache, not the network, per frame. */ +export async function loadReferencesFromServer(): Promise> { + const { data } = await apiClient.get("/api/sign-language/references"); + cache = data; + return countsFromCache(); } -function persist(store: ReferenceStore) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); +function countsFromCache(): Record { + return Object.fromEntries(Object.entries(cache).map(([word, samples]) => [word, samples.length])); +} + +/** word -> saved sample count, for display in the recording UI. Reads the in-memory cache -- call loadReferencesFromServer first to populate it. */ +export function listReferences(): Record { + return countsFromCache(); } // Layout matches landmarks.ts's extractFeatures: pose is 6 landmarks x [x,y,z,visibility] @@ -62,27 +69,19 @@ function normalizeSequence(frames: number[][]): number[][] { return frames.map(normalizeFrame); } -/** Stores one 30-frame landmark sample as a reference for `word`. Returns the sample count for that word. */ -export function saveReference(word: string, sequence: number[][]): number { - const store = loadReferences(); - const samples = [...(store[word] ?? []), normalizeSequence(sequence)].slice( - -MAX_SAMPLES_PER_WORD, - ); - store[word] = samples; - persist(store); - return samples.length; -} - -export function clearReference(word: string) { - const store = loadReferences(); - delete store[word]; - persist(store); +/** Replaces `word`'s entire reference set on the server with `samples` (each a raw, un-normalized landmark sequence) and updates the in-memory matching cache to match. A take is meant to supersede whatever was recorded before, not accumulate alongside it. */ +export async function replaceReferenceSamples(word: string, samples: number[][][]): Promise { + const normalized = samples.map(normalizeSequence).slice(-MAX_SAMPLES_PER_WORD); + await apiClient.put(`/api/sign-language/references/${encodeURIComponent(word)}`, { + samples: normalized, + }); + cache = { ...cache, [word]: normalized }; } -/** word -> saved sample count, for display in the recording UI. */ -export function listReferences(): Record { - const store = loadReferences(); - return Object.fromEntries(Object.entries(store).map(([word, samples]) => [word, samples.length])); +export async function deleteReference(word: string): Promise { + await apiClient.delete(`/api/sign-language/references/${encodeURIComponent(word)}`); + const { [word]: _removed, ...rest } = cache; + cache = rest; } function frameDistance(a: number[], b: number[]): number { @@ -130,18 +129,20 @@ export interface DtwMatch { distance: number; } -/** Finds the closest saved reference to `sequence` (raw, un-normalized), or null if nothing is within threshold. */ -export function matchReference( - sequence: number[][], - threshold = DEFAULT_DTW_THRESHOLD, -): DtwMatch | null { - const store = loadReferences(); +/** + * Finds the closest saved reference to `sequence` (raw, un-normalized), regardless of + * distance -- confirmation is unconditional (use-sign-capture.ts), so this is the only + * lookup recognition needs; DEFAULT_DTW_THRESHOLD is used only to scale the display + * similarity, not to gate this. Reads the in-memory cache synchronously (no network call) + * since this runs every video frame. + */ +export function closestReference(sequence: number[][]): DtwMatch | null { const normalized = normalizeSequence(sequence); let best: DtwMatch | null = null; - for (const [word, samples] of Object.entries(store)) { + for (const [word, samples] of Object.entries(cache)) { for (const sample of samples) { const distance = dtwDistance(normalized, sample); - if (distance <= threshold && (!best || distance < best.distance)) { + if (!best || distance < best.distance) { best = { word, distance }; } } diff --git a/src/features/sign-capture/model/sign-model.ts b/src/features/sign-capture/model/sign-model.ts deleted file mode 100644 index ad19287..0000000 --- a/src/features/sign-capture/model/sign-model.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type * as TF from "@tensorflow/tfjs"; - -const MODEL_URL = "/ksl/model/model.json"; - -// index 0 is the model's own "no sign" class and is never treated as a recognized word. -// See public/ksl/model/NOTICE.md for provenance and label-mapping verification. -export const SIGN_LABELS = [ - null, - "오늘", - "날씨", - "좋다", - "맛있다", - "식사", - "감사", - "안녕", - "소개", - "나", - "만나다", - "반갑다", -] as const; - -export const KSL_WORD_METADATA: Record = { - 오늘: { icon: "📅", description: "양손을 가슴 앞에서 아래로 가볍게 내리는 수어" }, - 날씨: { icon: "☀️", description: "손을 펴서 뺨이나 가슴 쪽에서 흔드는 수어" }, - 좋다: { icon: "👍", description: "엄지손가락을 세워 긍정과 만족을 나타내는 수어" }, - 맛있다: { icon: "😋", description: "손끝을 뺨이나 턱에 대고 톡톡 치는 수어" }, - 식사: { icon: "🍚", description: "손을 모아 입 쪽으로 가져가는 식사 수어" }, - 감사: { icon: "🙏", description: "왼손 등 위에 오른손을 얹어 톡톡 두드리는 수어" }, - 안녕: { icon: "👋", description: "손과 팔을 들어 반갑게 인사하는 수어" }, - 소개: { icon: "💁‍♂️", description: "손바닥을 위로 하여 부드럽게 펼치는 수어" }, - 나: { icon: "🙋", description: "검지손가락으로 자신의 가슴 중앙을 가리키는 수어" }, - 만나다: { icon: "👥", description: "양손을 가슴 중앙으로 모아 마주보는 수어" }, - 반갑다: { icon: "😊", description: "양 손바닥으로 가슴을 가볍게 쓸어내리는 수어" }, -}; - -export const CONFIDENCE_THRESHOLD = 0.75; - -// @tensorflow/tfjs is ~800kB and only needed by DEAF-mode signers, so it's -// dynamically imported (code-split) instead of pulled into the main call chunk. -let modelPromise: Promise<{ tf: typeof TF; model: TF.LayersModel }> | null = null; - -function loadSignModel(): Promise<{ tf: typeof TF; model: TF.LayersModel }> { - modelPromise ??= import("@tensorflow/tfjs").then(async (tf) => ({ - tf, - model: await tf.loadLayersModel(MODEL_URL), - })); - return modelPromise; -} - -export interface SignPrediction { - label: (typeof SIGN_LABELS)[number]; - confidence: number; -} - -/** Runs the 30x150 landmark window through the Legatalee LSTM classifier. */ -export async function predictSign(frames: number[][]): Promise { - const { tf, model } = await loadSignModel(); - const input = tf.tensor3d([frames]); - const output = model.predict(input) as TF.Tensor; - try { - const values = output.dataSync(); - let bestIndex = 0; - for (let i = 1; i < values.length; i += 1) { - if ((values[i] ?? -Infinity) > (values[bestIndex] ?? -Infinity)) bestIndex = i; - } - return { label: SIGN_LABELS[bestIndex] ?? null, confidence: values[bestIndex] ?? 0 }; - } finally { - input.dispose(); - output.dispose(); - } -} diff --git a/src/features/sign-capture/model/use-sign-capture.ts b/src/features/sign-capture/model/use-sign-capture.ts index 6c68636..8ade2ac 100644 --- a/src/features/sign-capture/model/use-sign-capture.ts +++ b/src/features/sign-capture/model/use-sign-capture.ts @@ -10,11 +10,12 @@ import type { CaptionType } from "@/entities/caption"; import { drawFullBodySkeleton, useHandLandmarker, usePoseLandmarker } from "@/shared/lib"; import { type ConversationTurn, composeSignSentence } from "../api/sign-api"; import { - clearReference, + closestReference, DEFAULT_DTW_THRESHOLD, + deleteReference, listReferences, - matchReference, - saveReference, + loadReferencesFromServer, + replaceReferenceSamples, } from "./dtw"; import { extractFeatures, @@ -22,8 +23,14 @@ import { RollingWindow, splitHandsByHandedness, } from "./landmarks"; -import { CONFIDENCE_THRESHOLD, KSL_WORD_METADATA, predictSign } from "./sign-model"; import type { RecognizedSign } from "./types"; +import { KSL_WORD_METADATA } from "./word-metadata"; + +/** Live DTW readout for the recognition-clarity debug strip -- not used for logic. */ +export interface RecognitionDebug { + dtwWord: string | null; + dtwDistance: number | null; +} export interface UseSignCaptureReturn { videoRef: RefObject; @@ -41,20 +48,112 @@ export interface UseSignCaptureReturn { recentSigns: RecognizedSign[]; recognizedText: string | null; wordBuffer: string[]; - composedSentence: string | null; + composedSentences: string[]; references: Record; + recognitionDebug: RecognitionDebug | null; + handsGoneSince: number | null; + isRecordingWord: boolean; + isSavingReference: boolean; + recordingSecond: number; + recordingTotalSeconds: number; + recordingTotalReps: number; + recordingRepIntervalSeconds: number; + recordingResult: { ok: boolean; message: string } | null; toggleCamera: () => void; toggleSkeleton: () => void; clearHistory: () => void; - recordReference: (word: string) => boolean; + startRecordingReference: (word: string) => boolean; + finishRecordingNow: () => void; + cancelRecordingReference: () => void; removeReference: (word: string) => void; } -const CONSECUTIVE_REQUIRED = 2; +// A DTW match already represents a whole-window judgment (the last 30 frames as a whole +// resemble a reference), not a per-frame class score, so a single frame under threshold is +// enough to confirm -- see updateCandidateStreak. +const REQUIRED_STREAK = 1; const NO_SIGN_STREAK_TO_RESET = 5; -const UTTERANCE_PAUSE_MS = 3000; +// If both hands haven't been detected for this long, treat it as the end of the signer's +// turn and send whatever's been confirmed so far off for translation. Replaces a +// pause-since-last-confirmed-word debounce, which didn't distinguish "hands down, done +// signing" from "hands still up, just pausing mid-sentence." +export const HANDS_GONE_FLUSH_MS = 1500; // How many prior turns (both sides combined) to send as context with each compose call. const MAX_HISTORY_TURNS = 12; +// How many past translated sentences to keep around for display -- translation keeps +// running for every utterance, not just the first, so this is a rolling log, not a cap on +// how many times compose can fire. +const MAX_DISPLAYED_SENTENCES = 20; +// Custom-word recording: the signer repeats the gesture once per REP_INTERVAL_SECONDS, and +// the continuous recording is split into one DTW reference sample per rep -- RECORD_REPS +// matches dtw.ts's MAX_SAMPLES_PER_WORD (10), so a single take fully replaces a word's +// reference set. REP_INTERVAL_SECONDS is deliberately close to how long the live recognition +// window (WINDOW_FRAMES=30 processed frames, landmarks.ts) actually spans in wall-clock time +// -- on a typical device running the hand+pose landmarkers per frame, that's on the order of +// 1-2s, not a flat 1s. Pacing reps faster than that risks each recorded sample capturing only +// part of the gesture, which DTW's time-warping can't fix (warping absorbs speed differences, +// not missing motion). +const RECORD_REPS = 10; +const REP_INTERVAL_SECONDS = 2; +const RECORD_TOTAL_SECONDS = RECORD_REPS * REP_INTERVAL_SECONDS; +// Skip a per-rep bucket that's mostly empty (hand out of frame, dropped frames) rather than +// saving a near-empty/garbage reference sample for it. +const MIN_SEGMENT_FRAMES = 5; + +interface ResolvedPrediction { + candidate: string | null; + confidence: number; + debug: RecognitionDebug; +} + +// Finds the closest DTW reference match for the current window. Recognition is DTW-only: a +// pretrained LSTM classifier for a fixed 11-word vocabulary used to run alongside this and be +// checked first, but it always emitted *some* confident top-1 guess (softmax over 12 classes, +// including "no action") for arbitrary hand motion, and since it was checked first it +// permanently shadowed DTW candidates -- a registered custom word could match its reference +// perfectly and still never be the one shown. Dropping it means every word, including a +// starter vocabulary, now has to actually be recorded by the signer, but recognition behavior +// becomes fully predictable: whatever you record is what gets recognized. Packages a debug +// snapshot alongside the result -- pulled out of handleLandmarkerResult to keep that +// callback's cognitive complexity down. +// +// Unconditional: whichever registered word is closest is the candidate, with no distance +// threshold gating confirmation -- once at least one word is registered there's no "nothing +// recognized" state (only handleLandmarkerResult's hands-not-detected check produces a null +// candidate). DEFAULT_DTW_THRESHOLD is used only to scale the debug/display similarity +// percentage, not to accept or reject a match. +function resolvePrediction(frames: number[][]): ResolvedPrediction { + const dtwBest = closestReference(frames); + const similarity = dtwBest ? Math.max(0, 1 - dtwBest.distance / DEFAULT_DTW_THRESHOLD) : 0; + + return { + candidate: dtwBest?.word ?? null, + confidence: similarity, + debug: { + dtwWord: dtwBest?.word ?? null, + dtwDistance: dtwBest?.distance ?? null, + }, + }; +} + +// Builds the live "인식 중" card from the closest DTW match, or null if there's no reference +// to compare against at all (no custom words registered yet). +function buildActiveSignFromClosest( + word: string | null, + similarity: number, +): RecognizedSign | null { + if (!word) return null; + const meta = KSL_WORD_METADATA[word]; + return { + id: `dtw_${word}`, + label: word, + description: meta?.description ?? `등록한 수어: ${word}`, + icon: meta?.icon ?? "🤟", + confidence: similarity, + category: "action", + timestamp: Date.now(), + }; +} function renderLandmarksToCanvas( canvas: HTMLCanvasElement | null, @@ -93,9 +192,9 @@ interface PredictionState { lastConfirmedCandidate: string | null; } -// Confirms a candidate once it has held steady for CONSECUTIVE_REQUIRED frames, as long as -// it isn't the same word already confirmed last (which would just be the signer continuing -// to hold the same pose). Gating on "already confirmed *this word*" rather than "already +// Confirms a candidate once it has held steady for REQUIRED_STREAK frames, as long as it +// isn't the same word already confirmed last (which would just be the signer continuing to +// hold the same pose). Gating on "already confirmed *this word*" rather than "already // confirmed *something* this segment" is what lets consecutive different words (A -> B with // no no-sign gap in between, since the recognizer doesn't reliably dip to null between two // signs performed back-to-back) each get confirmed on their own -- previously a single @@ -119,7 +218,7 @@ function updateCandidateStreak( return { isConfirmed: false }; } - if (state.candidateStreak >= CONSECUTIVE_REQUIRED && state.lastConfirmedCandidate !== candidate) { + if (state.candidateStreak >= REQUIRED_STREAK && state.lastConfirmedCandidate !== candidate) { state.lastConfirmedCandidate = candidate; return { isConfirmed: true }; } @@ -128,13 +227,16 @@ function updateCandidateStreak( } /** - * Captures webcam stream and recognizes signs purely from the 30-frame sequence window - * (LSTM for the trained 11 words, DTW for custom-recorded ones) -- sign language is + * Captures webcam stream and recognizes signs purely from the 30-frame sequence window via + * DTW matching against signer-recorded reference samples (see dtw.ts) -- sign language is * distinguished by motion over time, not a single frame, so there is deliberately no * single-frame/static-pose classifier here (an earlier version had one for zero-latency * generic gestures like "thumbs up"/numbers; it was removed because judging any KSL sign * from one still frame produced false positives, especially during the pause between - * words that the utterance-compose debounce relies on). + * words that the utterance-compose debounce relies on). There is also no pretrained + * classifier for a fixed vocabulary -- every recognized word has to be recorded by the + * signer first, which trades zero-setup recognition for fully predictable behavior (see + * resolvePrediction's docstring for why the earlier hybrid LSTM+DTW setup was dropped). * Accumulates confirmed words and converts them to natural sentences via LLM translation. */ export function useSignCapture( @@ -146,7 +248,7 @@ export function useSignCapture( const canvasRef = useRef(null); const streamRef = useRef(null); - // 30-Frame Rolling Window & LSTM State + // 30-Frame Rolling Window & DTW Recognition State const windowRef = useRef(new RollingWindow()); const latestPoseRef = useRef(null); const predictionStateRef = useRef({ @@ -154,17 +256,29 @@ export function useSignCapture( candidateStreak: 0, lastConfirmedCandidate: null, }); - const predictingRef = useRef(false); - // Word buffer & Utterance state for LLM translation + // Word buffer for LLM translation const wordBufferRef = useRef([]); - const utteranceTimerRef = useRef | null>(null); + + // Utterance-end detection: both hands absent for HANDS_GONE_FLUSH_MS triggers a send. + // handsGoneAtRef is the timestamp hands were last seen missing (null while visible); + // flushedForGapRef prevents re-sending repeatedly for the same absence. + const handsGoneAtRef = useRef(null); + const flushedForGapRef = useRef(false); // Conversation history for LLM context: this side's own composed sentences plus the // other side's final captions, in chronological order. const historyRef = useRef([]); const processedCaptionIdsRef = useRef>(new Set()); + // Custom-word (DTW) recording: continuously buffers frames for RECORD_TOTAL_SECONDS while + // recordingWordRef is set, then finishRecordingReference splits them into per-second samples. + const recordingWordRef = useRef(null); + const recordingFramesRef = useRef<{ t: number; f: number[] }[]>([]); + const recordingStartRef = useRef(0); + const recordingTickTimerRef = useRef | null>(null); + const recordingEndTimerRef = useRef | null>(null); + const [isCameraActive, setIsCameraActive] = useState(true); const [cameraError, setCameraError] = useState(false); const [showSkeleton, setShowSkeleton] = useState(true); @@ -176,8 +290,16 @@ export function useSignCapture( const [recentSigns, setRecentSigns] = useState([]); const [wordBuffer, setWordBuffer] = useState([]); const [recognizedText, setRecognizedText] = useState(null); - const [composedSentence, setComposedSentence] = useState(null); + const [composedSentences, setComposedSentences] = useState([]); const [references, setReferences] = useState>(() => listReferences()); + const [recognitionDebug, setRecognitionDebug] = useState(null); + const [handsGoneSince, setHandsGoneSince] = useState(null); + const [isRecordingWord, setIsRecordingWord] = useState(false); + const [isSavingReference, setIsSavingReference] = useState(false); + const [recordingSecond, setRecordingSecond] = useState(0); + const [recordingResult, setRecordingResult] = useState<{ ok: boolean; message: string } | null>( + null, + ); // Start webcam stream const startCamera = useCallback(async () => { @@ -222,6 +344,10 @@ export function useSignCapture( setDetectedHandsCount(0); setIsArmDetected(false); setActiveSign(null); + setRecognitionDebug(null); + handsGoneAtRef.current = null; + flushedForGapRef.current = false; + setHandsGoneSince(null); }, []); const toggleCamera = useCallback(() => { @@ -244,7 +370,7 @@ export function useSignCapture( wordBufferRef.current = []; setWordBuffer([]); setRecognizedText(null); - setComposedSentence(null); + setComposedSentences([]); }, []); // Initialize webcam on mount @@ -252,10 +378,28 @@ export function useSignCapture( startCamera(); return () => { stopCamera(); - if (utteranceTimerRef.current) clearTimeout(utteranceTimerRef.current); + if (recordingTickTimerRef.current) clearInterval(recordingTickTimerRef.current); + if (recordingEndTimerRef.current) clearTimeout(recordingEndTimerRef.current); }; }, [startCamera, stopCamera]); + // Load the global reference store once on mount -- recognition itself reads the + // in-memory cache dtw.ts keeps (synchronous, per video frame), so this is a one-time + // sync point, not something re-fetched per frame. + useEffect(() => { + let cancelled = false; + loadReferencesFromServer() + .then((counts) => { + if (!cancelled) setReferences(counts); + }) + .catch((err) => { + console.error("Failed to load sign word references:", err); + }); + return () => { + cancelled = true; + }; + }, []); + // Records each newly-finalized caption (the HEARING side's speech) into the shared // history so the next compose call has it as context. Interim captions are skipped — // only the final text for a given segment id is recorded, once. @@ -268,63 +412,59 @@ export function useSignCapture( } }, [captions]); - // Debounced LLM translation of word sequence. Fires UTTERANCE_PAUSE_MS after the last - // confirmed word; clears the buffer immediately so a word signed while this request is - // in flight starts a fresh utterance instead of being resent with the old one. - const flushUtterance = useCallback(() => { - if (utteranceTimerRef.current) clearTimeout(utteranceTimerRef.current); - utteranceTimerRef.current = setTimeout(() => { - const words = [...wordBufferRef.current]; - wordBufferRef.current = []; - setWordBuffer([]); - if (words.length === 0) return; - - setIsComposing(true); - // Snapshot history before this turn -- it must not include the sentence this - // call is about to produce. - const historyForThisTurn = historyRef.current; - composeSignSentence(words, historyForThisTurn) - .then((sentence) => { - setComposedSentence(sentence); - const turn: ConversationTurn = { speaker: "DEAF", text: sentence }; - historyRef.current = [...historyRef.current, turn].slice(-MAX_HISTORY_TURNS); - if (room) { - sendChatText(room, sentence); - } - onSentence?.(sentence); - }) - .catch(() => { - const fallback = words.join(" "); - setComposedSentence(fallback); - const turn: ConversationTurn = { speaker: "DEAF", text: fallback }; - historyRef.current = [...historyRef.current, turn].slice(-MAX_HISTORY_TURNS); - if (room) { - sendChatText(room, fallback); - } - onSentence?.(fallback); - }) - .finally(() => { - setIsComposing(false); - }); - }, UTTERANCE_PAUSE_MS); + // Sends whatever's in the word buffer off for LLM translation right now -- triggered when + // both hands have been gone for HANDS_GONE_FLUSH_MS (see handleLandmarkerResult), not on a + // timer per word. Clears the buffer immediately so a word signed while this request is in + // flight starts a fresh utterance instead of being resent with the old one. + const sendUtteranceNow = useCallback(() => { + const words = [...wordBufferRef.current]; + wordBufferRef.current = []; + setWordBuffer([]); + if (words.length === 0) return; + + setIsComposing(true); + // Snapshot history before this turn -- it must not include the sentence this call is + // about to produce. + const historyForThisTurn = historyRef.current; + composeSignSentence(words, historyForThisTurn) + .then((sentence) => { + setComposedSentences((prev) => [...prev, sentence].slice(-MAX_DISPLAYED_SENTENCES)); + const turn: ConversationTurn = { speaker: "DEAF", text: sentence }; + historyRef.current = [...historyRef.current, turn].slice(-MAX_HISTORY_TURNS); + if (room) { + sendChatText(room, sentence); + } + onSentence?.(sentence); + }) + .catch(() => { + const fallback = words.join(" "); + setComposedSentences((prev) => [...prev, fallback].slice(-MAX_DISPLAYED_SENTENCES)); + const turn: ConversationTurn = { speaker: "DEAF", text: fallback }; + historyRef.current = [...historyRef.current, turn].slice(-MAX_HISTORY_TURNS); + if (room) { + sendChatText(room, fallback); + } + onSentence?.(fallback); + }) + .finally(() => { + setIsComposing(false); + }); }, [room, onSentence]); - const commitConfirmedSign = useCallback( - (sign: RecognizedSign) => { - setLastConfirmedSign(sign); - setRecentSigns((prev) => [sign, ...prev.slice(0, 9)]); - - // Append word to sentence buffer (avoid consecutive duplicate words in buffer) - const lastWord = wordBufferRef.current[wordBufferRef.current.length - 1]; - if (lastWord !== sign.label) { - wordBufferRef.current = [...wordBufferRef.current, sign.label]; - setWordBuffer([...wordBufferRef.current]); - setRecognizedText(wordBufferRef.current.join(" ")); - flushUtterance(); - } - }, - [flushUtterance], - ); + const commitConfirmedSign = useCallback((sign: RecognizedSign) => { + setLastConfirmedSign(sign); + setRecentSigns((prev) => [sign, ...prev.slice(0, 9)]); + + // Append word to sentence buffer (avoid consecutive duplicate words in buffer). The + // buffer is sent off for translation when both hands go missing for + // HANDS_GONE_FLUSH_MS (handleLandmarkerResult), not from here. + const lastWord = wordBufferRef.current[wordBufferRef.current.length - 1]; + if (lastWord !== sign.label) { + wordBufferRef.current = [...wordBufferRef.current, sign.label]; + setWordBuffer([...wordBufferRef.current]); + setRecognizedText(wordBufferRef.current.join(" ")); + } + }, []); // Pose Landmarker hook for upper body / arm tracking usePoseLandmarker(videoRef, isCameraActive, (result: PoseLandmarkerResult) => { @@ -333,38 +473,20 @@ export function useSignCapture( setIsArmDetected(Boolean(pose && pose.length >= 17)); }); + // Confirms a candidate into the word buffer once it holds steady for REQUIRED_STREAK + // frames. Doesn't touch activeSign -- that's driven directly by the closest DTW match + // (see handleLandmarkerResult), independent of whether it clears the confirmation + // threshold. const handleKslPrediction = useCallback( (candidate: string | null, confidence: number) => { - const meta = candidate ? KSL_WORD_METADATA[candidate] : undefined; - const icon = meta?.icon ?? "🤟"; - const desc = meta?.description ?? `KSL 수어: ${candidate}`; - const { isConfirmed } = updateCandidateStreak(predictionStateRef.current, candidate); - - if (candidate) { - setActiveSign({ - id: `ksl_${candidate}`, - label: candidate, - description: desc, - icon, - confidence, - category: "action", - timestamp: Date.now(), - }); - } else if (predictionStateRef.current.candidateStreak >= NO_SIGN_STREAK_TO_RESET) { - // Only clear once the "no sign" streak is sustained, not on every single null - // tick -- otherwise the live indicator flickers off during brief detection - // gaps mid-gesture. This mirrors the same threshold updateCandidateStreak uses - // to reset the segment, so "recognition ended" and "indicator cleared" agree. - setActiveSign(null); - } - if (candidate && isConfirmed) { + const meta = KSL_WORD_METADATA[candidate]; const kslSign: RecognizedSign = { id: `ksl_${candidate}_${Date.now()}`, label: candidate, - description: desc, - icon, + description: meta?.description ?? `등록한 수어: ${candidate}`, + icon: meta?.icon ?? "🤟", confidence, category: "action", timestamp: Date.now(), @@ -375,12 +497,41 @@ export function useSignCapture( [commitConfirmedSign], ); - // Main frame processing callback: sequence-only recognition (LSTM + DTW), no - // single-frame static gesture path. + // Utterance-end detection: both hands gone for HANDS_GONE_FLUSH_MS sends whatever has been + // confirmed so far, instead of waiting on a per-word pause timer. Pulled out of + // handleLandmarkerResult to keep that callback's cognitive complexity down. + const updateHandsGoneTracking = useCallback( + (handsLength: number) => { + if (handsLength > 0) { + if (handsGoneAtRef.current !== null) { + handsGoneAtRef.current = null; + flushedForGapRef.current = false; + setHandsGoneSince(null); + } + return; + } + + if (handsGoneAtRef.current === null) { + handsGoneAtRef.current = performance.now(); + setHandsGoneSince(handsGoneAtRef.current); + } else if ( + !flushedForGapRef.current && + performance.now() - handsGoneAtRef.current >= HANDS_GONE_FLUSH_MS + ) { + flushedForGapRef.current = true; + sendUtteranceNow(); + } + }, + [sendUtteranceNow], + ); + + // Main frame processing callback: sequence-only DTW recognition, no single-frame + // static gesture path. const handleLandmarkerResult = useCallback( (handResult: HandLandmarkerResult) => { const hands = handResult.landmarks ?? []; setDetectedHandsCount(hands.length); + updateHandsGoneTracking(hands.length); const poseLandmarks = latestPoseLandmarks(latestPoseRef.current); renderLandmarksToCanvas( @@ -392,39 +543,45 @@ export function useSignCapture( ); const { leftHandLandmarks, rightHandLandmarks } = splitHandsByHandedness(handResult); - windowRef.current.push( - extractFeatures({ poseLandmarks, leftHandLandmarks, rightHandLandmarks }), - ); + const features = extractFeatures({ poseLandmarks, leftHandLandmarks, rightHandLandmarks }); + windowRef.current.push(features); + if (recordingWordRef.current) { + recordingFramesRef.current.push({ + t: performance.now() - recordingStartRef.current, + f: features, + }); + } + + // While a custom-word take is recording, skip live recognition entirely -- the + // signer is deliberately repeating one gesture on a beat for registration, not + // signing normally, so running the recognizer here would only risk polluting the + // word buffer/LLM compose flow with junk from the practice reps. + if (recordingWordRef.current) return; + + // No hands in frame means no gesture is happening -- skip DTW lookup entirely + // rather than matching a zero-filled hand vector against whatever's registered + // (which, since confirmation is unconditional, would otherwise keep confirming + // some "closest" word even with nobody signing). + if (hands.length === 0) { + setActiveSign(null); + handleKslPrediction(null, 0); + return; + } const frames = windowRef.current.toArray(); - if (!frames || predictingRef.current) return; - - predictingRef.current = true; - predictSign(frames) - .then(({ label, confidence }) => { - const lstmCandidate = label && confidence >= CONFIDENCE_THRESHOLD ? label : null; - const dtwMatch = matchReference(frames); - const finalCandidate = lstmCandidate ?? dtwMatch?.word ?? null; - // DTW has no natural 0-1 confidence -- derive one from how close the match - // distance is to the threshold, so a borderline match doesn't look as - // trustworthy in the UI as a near-exact one. - const dtwConfidence = dtwMatch - ? Math.max(0, 1 - dtwMatch.distance / DEFAULT_DTW_THRESHOLD) - : 0; - const finalConfidence = lstmCandidate ? confidence : dtwConfidence; - - if (finalCandidate) { - handleKslPrediction(finalCandidate, finalConfidence); - } - }) - .catch((err) => { - console.error("Sign prediction error:", err); - }) - .finally(() => { - predictingRef.current = false; - }); + if (!frames) return; + + const { candidate, confidence, debug } = resolvePrediction(frames); + setRecognitionDebug(debug); + // Always show the closest match as a live card, however low the similarity -- lets + // the signer see "this is what it thinks you're doing." Confirmation into the + // sentence buffer is unconditional too (see resolvePrediction), so this is also + // exactly what's about to be added to the word list. + setActiveSign(buildActiveSignFromClosest(candidate, confidence)); + + handleKslPrediction(candidate, confidence); }, - [showSkeleton, handleKslPrediction], + [showSkeleton, handleKslPrediction, updateHandsGoneTracking], ); const { isLoading: isLoadingModel, isReady: isModelReady } = useHandLandmarker( @@ -433,17 +590,109 @@ export function useSignCapture( handleLandmarkerResult, ); - const recordReference = useCallback((word: string): boolean => { - const frames = windowRef.current.toArray(); - if (!frames || !word.trim()) return false; - saveReference(word.trim(), frames); - setReferences(listReferences()); - return true; + // Splits the just-recorded continuous take into one reference sample per rep (bucketed by + // elapsed time, not frame count, so it's robust to frame-rate jitter) and replaces the + // word's entire reference set with them -- a take is meant to supersede whatever was + // recorded before, not accumulate alongside it. Also callable early (finishRecordingNow) + // so the signer isn't locked into waiting the full RECORD_TOTAL_SECONDS if they've already + // done enough reps -- whatever reps completed so far still get saved, unlike + // cancelRecordingReference which discards everything. + const finishRecordingReference = useCallback(() => { + if (recordingTickTimerRef.current) clearInterval(recordingTickTimerRef.current); + if (recordingEndTimerRef.current) clearTimeout(recordingEndTimerRef.current); + recordingTickTimerRef.current = null; + recordingEndTimerRef.current = null; + + const word = recordingWordRef.current; + const frames = recordingFramesRef.current; + recordingWordRef.current = null; + recordingFramesRef.current = []; + setIsRecordingWord(false); + setRecordingSecond(0); + if (!word) return; + + const buckets: number[][][] = Array.from({ length: RECORD_REPS }, () => []); + for (const { t, f } of frames) { + const idx = Math.min(RECORD_REPS - 1, Math.floor(t / (REP_INTERVAL_SECONDS * 1000))); + buckets[idx]?.push(f); + } + const validSamples = buckets.filter((bucket) => bucket.length >= MIN_SEGMENT_FRAMES); + + if (validSamples.length === 0) { + setRecordingResult({ + ok: false, + message: "동작이 감지되지 않았습니다. 손이 잘 보이는 곳에서 다시 시도해주세요.", + }); + return; + } + + setIsSavingReference(true); + replaceReferenceSamples(word, validSamples) + .then(() => { + setReferences(listReferences()); + setRecordingResult({ + ok: true, + message: `'${word}' 동작 샘플 ${validSamples.length}개를 저장했습니다.`, + }); + }) + .catch((err) => { + console.error("Failed to save sign word reference:", err); + setRecordingResult({ + ok: false, + message: "저장에 실패했습니다. 네트워크 연결을 확인하고 다시 시도해주세요.", + }); + }) + .finally(() => { + setIsSavingReference(false); + }); + }, []); + + const startRecordingReference = useCallback( + (word: string): boolean => { + const trimmed = word.trim(); + if (!trimmed || recordingWordRef.current) return false; + + recordingWordRef.current = trimmed; + recordingFramesRef.current = []; + recordingStartRef.current = performance.now(); + setRecordingResult(null); + setRecordingSecond(1); + setIsRecordingWord(true); + setRecognitionDebug(null); + + recordingTickTimerRef.current = setInterval(() => { + const elapsedMs = performance.now() - recordingStartRef.current; + setRecordingSecond(Math.min(RECORD_TOTAL_SECONDS, Math.floor(elapsedMs / 1000) + 1)); + }, 1000); + recordingEndTimerRef.current = setTimeout( + finishRecordingReference, + RECORD_TOTAL_SECONDS * 1000, + ); + + return true; + }, + [finishRecordingReference], + ); + + const cancelRecordingReference = useCallback(() => { + if (!recordingWordRef.current) return; + if (recordingTickTimerRef.current) clearInterval(recordingTickTimerRef.current); + if (recordingEndTimerRef.current) clearTimeout(recordingEndTimerRef.current); + recordingTickTimerRef.current = null; + recordingEndTimerRef.current = null; + recordingWordRef.current = null; + recordingFramesRef.current = []; + setIsRecordingWord(false); + setRecordingSecond(0); + setRecordingResult(null); }, []); const removeReference = useCallback((word: string) => { - clearReference(word); - setReferences(listReferences()); + deleteReference(word) + .then(() => setReferences(listReferences())) + .catch((err) => { + console.error("Failed to delete sign word reference:", err); + }); }, []); return { @@ -462,12 +711,23 @@ export function useSignCapture( recentSigns, wordBuffer, recognizedText, - composedSentence, + composedSentences, references, + recognitionDebug, + handsGoneSince, + isRecordingWord, + isSavingReference, + recordingSecond, + recordingTotalSeconds: RECORD_TOTAL_SECONDS, + recordingTotalReps: RECORD_REPS, + recordingRepIntervalSeconds: REP_INTERVAL_SECONDS, + recordingResult, toggleCamera, toggleSkeleton, clearHistory, - recordReference, + startRecordingReference, + finishRecordingNow: finishRecordingReference, + cancelRecordingReference, removeReference, }; } diff --git a/src/features/sign-capture/model/word-metadata.ts b/src/features/sign-capture/model/word-metadata.ts new file mode 100644 index 0000000..276f38a --- /dev/null +++ b/src/features/sign-capture/model/word-metadata.ts @@ -0,0 +1,17 @@ +// Icons/descriptions for a starter KSL vocabulary -- purely a display/onboarding aid (the +// vocabulary guide, and metadata for a recognized word if it happens to match one of these +// names). Recognition itself is DTW-only now: every word, including these, has to actually be +// recorded by the signer via the "고급: 나만의 수어 단어 등록" flow before it's recognized. +export const KSL_WORD_METADATA: Record = { + 오늘: { icon: "📅", description: "양손을 가슴 앞에서 아래로 가볍게 내리는 수어" }, + 날씨: { icon: "☀️", description: "손을 펴서 뺨이나 가슴 쪽에서 흔드는 수어" }, + 좋다: { icon: "👍", description: "엄지손가락을 세워 긍정과 만족을 나타내는 수어" }, + 맛있다: { icon: "😋", description: "손끝을 뺨이나 턱에 대고 톡톡 치는 수어" }, + 식사: { icon: "🍚", description: "손을 모아 입 쪽으로 가져가는 식사 수어" }, + 감사: { icon: "🙏", description: "왼손 등 위에 오른손을 얹어 톡톡 두드리는 수어" }, + 안녕: { icon: "👋", description: "손과 팔을 들어 반갑게 인사하는 수어" }, + 소개: { icon: "💁‍♂️", description: "손바닥을 위로 하여 부드럽게 펼치는 수어" }, + 나: { icon: "🙋", description: "검지손가락으로 자신의 가슴 중앙을 가리키는 수어" }, + 만나다: { icon: "👥", description: "양손을 가슴 중앙으로 모아 마주보는 수어" }, + 반갑다: { icon: "😊", description: "양 손바닥으로 가슴을 가볍게 쓸어내리는 수어" }, +}; diff --git a/src/features/sign-capture/ui/sign-capture-view.tsx b/src/features/sign-capture/ui/sign-capture-view.tsx index a570832..d82102e 100644 --- a/src/features/sign-capture/ui/sign-capture-view.tsx +++ b/src/features/sign-capture/ui/sign-capture-view.tsx @@ -16,17 +16,22 @@ import { Trash2, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { CaptionType } from "@/entities/caption"; import { Button, Input } from "@/shared/ui"; -import { KSL_WORD_METADATA } from "../model/sign-model"; +import { DEFAULT_DTW_THRESHOLD } from "../model/dtw"; import type { RecognizedSign } from "../model/types"; -import { useSignCapture } from "../model/use-sign-capture"; - -// Only the real, sequence-trained KSL words -- the guide used to also list a handful of -// single-frame static gestures (thumbs up, OK sign, finger-counted numbers), but that -// recognizer was removed (see use-sign-capture.ts's docstring), so listing them here -// would promise something that no longer works. +import { + HANDS_GONE_FLUSH_MS, + type RecognitionDebug, + useSignCapture, +} from "../model/use-sign-capture"; +import { KSL_WORD_METADATA } from "../model/word-metadata"; + +// Suggested starter vocabulary for the guide -- recognition is DTW-only now, so none of these +// (or any word) are recognized until the signer actually records them via the "고급: 나만의 +// 수어 단어 등록" flow. This list is just icons/descriptions to help someone decide what to +// record first, not a promise that these already work. const KSL_VOCABULARY_LIST = Object.entries(KSL_WORD_METADATA).map(([label, meta]) => ({ label, icon: meta.icon, @@ -58,7 +63,7 @@ function CameraStatusBar({ {isModelReady && isCameraActive && ( - 하이브리드 AI + 동작 인식 준비됨 )} @@ -91,92 +96,124 @@ function CameraStatusBar({ ); } -function ActiveSignOverlay({ - activeSign, +// Live single-word recognition indicator -- a compact badge, not the full accumulated +// caption (the translated-sentence list below now covers that role), just enough to +// confirm recognition is actually running while signing. +function ActiveSignBadge({ activeSign }: { activeSign: RecognizedSign | null }) { + if (!activeSign) return null; + + return ( +
+ {activeSign.icon} + {activeSign.label} + + + 인식 중 + +
+ ); +} + +// Always-on technical readout of what the recognizer is actually seeing -- the closest DTW +// reference (even above threshold, so a near-miss custom word is visible instead of the +// recognizer just looking silently broken). +function RecognitionDebugStrip({ debug }: { debug: RecognitionDebug | null }) { + if (!debug) return null; + + const dtwText = debug.dtwWord + ? `${debug.dtwWord} · 거리 ${debug.dtwDistance?.toFixed(2)} / 임계값 ${DEFAULT_DTW_THRESHOLD}` + : "저장된 커스텀 단어 없음"; + + return ( +
+ DTW 최근접: {dtwText} +
+ ); +} + +// Ticks locally (display-only) so the signer knows exactly how much longer their hands can +// stay out of frame before the buffered words get sent off for translation -- the utterance +// ends when both hands have been gone for HANDS_GONE_FLUSH_MS, not after a pause since the +// last confirmed word. +function UtteranceCountdown({ wordBuffer, + handsGoneSince, isComposing, }: { - activeSign: RecognizedSign | null; wordBuffer: string[]; + handsGoneSince: number | null; isComposing: boolean; }) { - // Caption-style accumulated words ("날씨 맛있다"), not chips -- the live single-word - // indicator (below) is a separate, smaller strip so it no longer blocks the caption once - // a second word starts accumulating. - const caption = wordBuffer.length > 0 ? wordBuffer.join(" ") : null; + const pending = wordBuffer.length > 0 && !isComposing && handsGoneSince !== null; + // handsGoneSince is a performance.now() timestamp (see use-sign-capture.ts), so this must + // tick on the same clock -- Date.now() would produce a nonsense diff. + const [now, setNow] = useState(() => performance.now()); - return ( -
- {activeSign && ( -
- {activeSign.icon} - {activeSign.label} - - {Math.round(activeSign.confidence * 100)}% - - - - 인식 중 - -
- )} + useEffect(() => { + if (!pending) return; + const id = setInterval(() => setNow(performance.now()), 200); + return () => clearInterval(id); + }, [pending]); - {caption ? ( -
- {isComposing ? ( - - ) : ( - - )} - {caption} -
- ) : ( - !activeSign && ( -
- - 수어 동작이나 제스처를 취하면 실시간으로 인식되고 문장으로 합성됩니다 -
- ) - )} -
+ if (!pending || handsGoneSince === null) return null; + + const remainingMs = HANDS_GONE_FLUSH_MS - (now - handsGoneSince); + if (remainingMs <= 0) return null; + + return ( +

+ 손이 {Math.ceil(remainingMs / 1000)}초간 더 안 보이면 "{wordBuffer.join(" ")}"를 문장으로 + 번역합니다. +

); } +const MAX_SHOWN_SENTENCES = 5; + +// Rolling log of translated sentences (newest first) rather than a single line that gets +// replaced -- translation keeps firing for every utterance, and showing only the latest one +// made it look like it only ever ran once. function SentenceResultBanner({ - composedSentence, + composedSentences, isComposing, }: { - composedSentence: string | null; + composedSentences: string[]; isComposing: boolean; }) { - if (!composedSentence && !isComposing) return null; + if (composedSentences.length === 0 && !isComposing) return null; + + const recent = composedSentences + .map((sentence, index) => ({ sentence, index })) + .slice(-MAX_SHOWN_SENTENCES) + .reverse(); return ( -
-
-
+
+
+
{isComposing ? ( ) : ( )}
-
-
- - {isComposing ? "LLM 자연어 문장 변환 중..." : "AI 실시간 번역 문장"} - -
-

- {composedSentence ?? "단어들을 자연스러운 문장으로 조합하고 있습니다..."} + + {isComposing ? "LLM 자연어 문장 변환 중..." : "AI 실시간 번역 문장"} + +

+
+ {isComposing && recent.length === 0 && ( +

단어들을 자연스러운 문장으로 조합하고 있습니다...

+ )} + {recent.map(({ sentence, index }, i) => ( +

+ {sentence}

-
+ ))}
- {!isComposing && ( - - 음성 발화 완료 - - )}
); } @@ -250,26 +287,43 @@ function RecentSignsHistory({ function CustomWordRecorder({ references, - onRecord, + isRecording, + isSaving, + recordingSecond, + recordingTotalSeconds, + recordingTotalReps, + recordingRepIntervalSeconds, + recordingResult, + onStartRecording, + onFinishNow, + onCancelRecording, onRemove, }: { references: Record; - onRecord: (word: string) => boolean; + isRecording: boolean; + isSaving: boolean; + recordingSecond: number; + recordingTotalSeconds: number; + recordingTotalReps: number; + recordingRepIntervalSeconds: number; + recordingResult: { ok: boolean; message: string } | null; + onStartRecording: (word: string) => boolean; + onFinishNow: () => void; + onCancelRecording: () => void; onRemove: (word: string) => void; }) { + // recordingSecond is elapsed whole seconds (1..recordingTotalSeconds); derive which rep + // beat that falls into so the cadence bar shows reps, not raw seconds. + const currentRep = Math.min( + recordingTotalReps, + Math.floor((recordingSecond - 1) / recordingRepIntervalSeconds) + 1, + ); const [newWord, setNewWord] = useState(""); - const [recordHint, setRecordHint] = useState(null); - const handleRecord = () => { + const handleStart = () => { const word = newWord.trim(); if (!word) return; - const ok = onRecord(word); - if (ok) { - setRecordHint(`'${word}' 동작 샘플을 저장했습니다.`); - setNewWord(""); - } else { - setRecordHint("카메라 프레임이 충분히 모이지 않았습니다. 잠시 후 다시 눌러주세요."); - } + onStartRecording(word); }; return ( @@ -280,27 +334,93 @@ function CustomWordRecorder({

- 기본 모델에 없는 단어(예: 병원, 예약, 도움)를 카메라 앞에서 표현한 뒤 "이 동작 저장"을 - 누르면, 브라우저가 기억하여 다음부터 인식합니다. + 기본 모델에 없는 단어(예: 병원, 예약, 도움)를 등록해보세요. "학습 시작"을 누르면{" "} + {recordingTotalSeconds}초 동안 {recordingRepIntervalSeconds}초에 한 번씩 그 단어의 동작을 + 반복해주시면(총 {recordingTotalReps}번) 자동으로 나눠서 저장합니다. 등록한 단어는 모든 + 사용자에게 공유됩니다.

-
+
setNewWord(e.target.value)} + disabled={isRecording || isSaving} className="h-9 border-neutral-700 bg-neutral-800/80 text-xs text-white" /> - + {isRecording ? ( + <> + + + + ) : ( + + )}
- {recordHint &&

{recordHint}

} + + {isRecording && ( +
+
+ + {recordingRepIntervalSeconds}초에 한 번씩 동작을 반복해주세요 + + + {currentRep}/{recordingTotalReps} + +
+ {/* Cadence bar: one segment per rep (not per second) -- the current beat + pulses as the cue for "repeat the gesture now" rather than making the + signer read and do math on a raw seconds counter. */} +
+ {Array.from({ length: recordingTotalReps }, (_, i) => { + const isPast = i < currentRep - 1; + const isCurrentBeat = i === currentRep - 1; + return ( +
+ ); + })} +
+

+ 반복이 끝났다면 {recordingTotalSeconds}초를 다 기다리지 않고 "완료(지금까지 저장)"를 + 눌러도 그때까지 녹화된 반복이 저장됩니다. +

+
+ )} + + {!isRecording && recordingResult && ( +

+ {recordingResult.message} +

+ )} + {Object.keys(references).length > 0 && (
    {Object.entries(references).map(([word, count]) => ( @@ -311,7 +431,8 @@ function CustomWordRecorder({ - ) : ( -
    - )} +
    diff --git a/src/pages/profile/ui/profile-page.tsx b/src/pages/profile/ui/profile-page.tsx index 274007b..0f89689 100644 --- a/src/pages/profile/ui/profile-page.tsx +++ b/src/pages/profile/ui/profile-page.tsx @@ -17,6 +17,7 @@ import { setCallPreferences, setMyPhone, } from "@/entities/call"; +import { SignCaptureView } from "@/features/sign-capture"; import { snackbar } from "@/shared/lib"; import { ActionButton, @@ -57,6 +58,7 @@ export function ProfilePage() { const topBar = useCompactTopBar(); const [phone, setPhone] = useState(getMyPhone); const [preferences, setPreferencesState] = useState(getCallPreferences); + const [showSignTraining, setShowSignTraining] = useState(false); const updatePreference = ( key: Key, value: CallPreferences[Key], @@ -172,6 +174,28 @@ export function ProfilePage() { />
    +
    + + 수어 단어 학습 + +

    + 모델에 없는 수어 단어를 카메라로 직접 녹화해서 등록할 수 있어요. 등록한 단어는 모든 + 사용자에게 공유돼요. +

    + setShowSignTraining((prev) => !prev)} + > + {showSignTraining ? "카메라 닫기" : "카메라로 단어 학습하기"} + + {showSignTraining && ( +
    + +
    + )} +
    diff --git a/src/pages/test-camera/index.ts b/src/pages/test-camera/index.ts deleted file mode 100644 index 2a68243..0000000 --- a/src/pages/test-camera/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TestCameraPage } from "./ui/test-camera-page"; diff --git a/src/pages/test-camera/ui/test-camera-page.tsx b/src/pages/test-camera/ui/test-camera-page.tsx deleted file mode 100644 index 72d5906..0000000 --- a/src/pages/test-camera/ui/test-camera-page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { ArrowLeft, Camera, Sparkles } from "lucide-react"; -import { SignCaptureView } from "@/features/sign-capture"; - -export function TestCameraPage() { - const navigate = useNavigate(); - - return ( -
    -
    - -
    - - 수어 카메라 테스트 모드 -
    -
    - -
    -
    -
    - - - 통화 연결 없이 실시간 웹캠 관절 추적, KSL 수어 인식, DTW 커스텀 단어 등록을 테스트할 - 수 있습니다. - -
    -
    - -
    -
    - ); -} diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index c620027..a5db3c9 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,6 +1,5 @@ export { cn } from "./cn"; export { dayjs } from "./dayjs"; -export { isLocalOrPreview } from "./is-preview"; export { connectRoom, onRoomText, sendRoomText } from "./livekit"; export { SnackbarBridge, snackbar } from "./snackbar"; export { diff --git a/src/shared/lib/is-preview.ts b/src/shared/lib/is-preview.ts deleted file mode 100644 index 52fc33a..0000000 --- a/src/shared/lib/is-preview.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Returns true if running in local development (vite dev) or preview deployment (Cloudflare pages.dev / PR preview). - */ -export function isLocalOrPreview(): boolean { - if (import.meta.env.DEV) return true; - if (typeof window !== "undefined") { - const host = window.location.hostname; - return ( - host === "localhost" || - host === "127.0.0.1" || - host.includes("pages.dev") || - host.includes("preview") || - window.location.search.includes("preview=true") - ); - } - return false; -}