Add AI face capability demos
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# demo-ai-face-detector
|
||||
|
||||
CUTOS 4.0 Vue 3/Vite LWA demonstrating local browser-side face detection with `@cutos/ai-face-detector`.
|
||||
|
||||
No Device Provider or Gateway service is required. The LWA needs camera permission and packages the MediaPipe model/WASM resources for offline use.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Starts the user-facing camera automatically.
|
||||
- Uses the source camera ratio in landscape and a centre-cropped 1:1 preview in portrait.
|
||||
- Guides the user with a circular capture area and a moving confidence box.
|
||||
- Requires one face, confidence ≥ 0.90, face size ≥ 12%, and horizontal centering.
|
||||
- Requires the accepted face position and size to remain stable for about two seconds.
|
||||
- Locks the stable face image and stops inference while keeping the camera preview visible.
|
||||
- Restarts detection automatically after ten seconds, or immediately when `Detect again` is selected.
|
||||
- Displays the locked face in the lower-right corner and keeps Detection Result and Activity in two equal cards.
|
||||
|
||||
## Development with the sibling SDK
|
||||
|
||||
```sh
|
||||
cd ../sdk
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
cd ../demo-ai-face-detector
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Both `dev` and the CUTOS build copy version-matched model assets through the SDK's asset command.
|
||||
|
||||
## Build and package
|
||||
|
||||
```sh
|
||||
npx cutos lwa build
|
||||
```
|
||||
|
||||
The generated package is:
|
||||
|
||||
```text
|
||||
release/demo-ai-face-detector-v<version>.lwa
|
||||
```
|
||||
|
||||
The LWA version comes from `public/config.json`; `package.json` intentionally remains `0.0.0`.
|
||||
|
||||
## Upload and publish
|
||||
|
||||
```sh
|
||||
npx cutos login --username office
|
||||
npx cutos lwa upload
|
||||
npx cutos lwa list demo-ai-face-detector
|
||||
npx cutos lwa publish --lwa <content-id> --device <device-id>
|
||||
```
|
||||
|
||||
For a release build, depend on the published npm SDK rather than `file:../sdk`.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CUTOS AI Face Detector</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "demo-ai-face-detector",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"assets": "cutos-ai-face-detector-assets public/cutos-ai-face-detector",
|
||||
"dev": "npm run assets && vite",
|
||||
"build": "cutos lwa build",
|
||||
"build:app": "npm run assets && vue-tsc --noEmit && vite build",
|
||||
"package": "cutos lwa package",
|
||||
"validate": "cutos lwa validate",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cutos/ai-face-detector": "^4.0.1",
|
||||
"@cutos/core": "^4.0.8",
|
||||
"vue": "^3.4.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"postcss-import": "^16.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.0",
|
||||
"vite": "^5.3.4",
|
||||
"vue-tsc": "^2.0.26"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "demo-ai-face-detector",
|
||||
"version": "0.2.6",
|
||||
"description": "CUTOS 4.0 browser-side AI face detector demo",
|
||||
"params": {
|
||||
"title": "CUTOS AI Face Detector",
|
||||
"host": "localhost"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { CoreAPI } from '@cutos/core'
|
||||
import { FaceDetector, type FaceDetectionResult, type FaceDetectorDiagnostics } from '@cutos/ai-face-detector'
|
||||
import CutosTopbar from '@/components/CutosTopbar.vue'
|
||||
import { loadConfig } from '@/utils/config'
|
||||
|
||||
interface ActivityItem { id: number; time: string; message: string }
|
||||
|
||||
const video = ref<HTMLVideoElement | null>(null)
|
||||
const title = ref('CUTOS AI Face Detector')
|
||||
const host = ref('localhost')
|
||||
const lwaName = ref('')
|
||||
const lwaVersion = ref('')
|
||||
const coreVersion = ref('')
|
||||
const runtimeConnected = ref(false)
|
||||
const detectorReady = ref(false)
|
||||
const cameraActive = ref(false)
|
||||
const busy = ref('')
|
||||
const errorText = ref('')
|
||||
const diagnostics = ref<FaceDetectorDiagnostics | null>(null)
|
||||
const result = ref<FaceDetectionResult | null>(null)
|
||||
const detectedFace = ref('')
|
||||
const activity = ref<ActivityItem[]>([])
|
||||
const portraitLayout = ref(false)
|
||||
const detectionLocked = ref(false)
|
||||
const stabilityProgress = ref(0)
|
||||
const restartCountdown = ref(10)
|
||||
|
||||
let detector: FaceDetector | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let animationFrame = 0
|
||||
let inferenceRunning = false
|
||||
let lastResultTimestamp = 0
|
||||
let lastQualityState = ''
|
||||
let stableSince = 0
|
||||
let stableBox: FaceDetectionResult['primary'] = null
|
||||
let restartTimer = 0
|
||||
|
||||
const STABILITY_DURATION_MS = 2000
|
||||
|
||||
const guideCircle = computed(() => {
|
||||
const frame = result.value?.frame
|
||||
if (!frame) return null
|
||||
const guideAspectRatio = portraitLayout.value ? 1 : frame.width / frame.height
|
||||
const sourceAspectRatio = frame.width / frame.height
|
||||
const visibleWidth = sourceAspectRatio > guideAspectRatio ? frame.height * guideAspectRatio : frame.width
|
||||
const visibleHeight = sourceAspectRatio > guideAspectRatio ? frame.height : frame.width / guideAspectRatio
|
||||
return {
|
||||
viewBox: `0 0 ${frame.width} ${frame.height}`,
|
||||
cx: frame.width / 2,
|
||||
cy: frame.height / 2,
|
||||
radius: Math.min(visibleWidth, visibleHeight) * 0.38
|
||||
}
|
||||
})
|
||||
const qualityMessage = computed(() => {
|
||||
const current = result.value
|
||||
if (!current?.primary) return 'No face detected'
|
||||
if (detectionLocked.value) return `Face locked · restart in ${restartCountdown.value}s`
|
||||
if (current.quality.accepted) return `Hold still · ${Math.round(stabilityProgress.value * 100)}%`
|
||||
return current.quality.reasons.map(reason => ({
|
||||
'multiple-faces': 'Keep only one face in view',
|
||||
'low-confidence': 'Improve lighting and face the camera',
|
||||
'face-too-small': 'Move closer to the camera',
|
||||
'face-off-center': 'Move your face towards the centre',
|
||||
'no-face': 'No face detected'
|
||||
})[reason]).join(' · ')
|
||||
})
|
||||
const resultSummary = computed(() => result.value ? {
|
||||
timestamp: new Date(result.value.timestamp).toLocaleTimeString(),
|
||||
frame: result.value.frame,
|
||||
faceCount: result.value.faceCount,
|
||||
score: result.value.primary?.score,
|
||||
box: result.value.primary?.box,
|
||||
quality: result.value.quality
|
||||
} : null)
|
||||
|
||||
function format(value: unknown) {
|
||||
return value == null ? 'No data' : JSON.stringify(value, null, 2)
|
||||
}
|
||||
function log(message: string) {
|
||||
activity.value.unshift({ id: Date.now() + Math.random(), time: new Date().toLocaleTimeString(), message })
|
||||
activity.value = activity.value.slice(0, 20)
|
||||
}
|
||||
async function run(name: string, action: () => Promise<void>) {
|
||||
busy.value = name
|
||||
errorText.value = ''
|
||||
try { await action() } catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : String(error)
|
||||
log(errorText.value)
|
||||
} finally { busy.value = '' }
|
||||
}
|
||||
async function initializeDetector() {
|
||||
if (detector) return
|
||||
await run('initialize', async () => {
|
||||
detector = await FaceDetector.create({
|
||||
assetBaseUrl: new URL('cutos-ai-face-detector', window.location.href).toString(),
|
||||
preferredDelegate: 'gpu',
|
||||
allowCpuFallback: true,
|
||||
minIntervalMs: 200,
|
||||
minDetectionConfidence: 0.9,
|
||||
minFaceSizeRatio: 0.12,
|
||||
maxCenterOffsetRatio: 0.38,
|
||||
maxHorizontalCenterOffsetRatio: 0.16,
|
||||
guideAspectRatio: portraitLayout.value ? 1 : 0
|
||||
})
|
||||
diagnostics.value = detector.getDiagnostics()
|
||||
detectorReady.value = true
|
||||
log(`Detector initialized with ${diagnostics.value.delegate}`)
|
||||
})
|
||||
}
|
||||
async function initializeRuntime() {
|
||||
try {
|
||||
await CoreAPI.init(host.value)
|
||||
runtimeConnected.value = CoreAPI.connected()
|
||||
coreVersion.value = CoreAPI.getVersion()
|
||||
} catch (error) {
|
||||
log(`CUTOS Runtime unavailable: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
async function startCamera() {
|
||||
if (cameraActive.value) return
|
||||
await run('camera', async () => {
|
||||
await initializeDetector()
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: portraitLayout.value
|
||||
? { width: { ideal: 720 }, height: { ideal: 720 }, aspectRatio: { ideal: 1 }, facingMode: 'user' }
|
||||
: { width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 }, facingMode: 'user' },
|
||||
audio: false
|
||||
})
|
||||
detectionLocked.value = false
|
||||
resetStability()
|
||||
cameraActive.value = true
|
||||
await nextTick()
|
||||
if (!video.value) throw new Error('Video element is unavailable')
|
||||
video.value.srcObject = stream
|
||||
await video.value.play()
|
||||
log(`Camera started at ${video.value.videoWidth}×${video.value.videoHeight}`)
|
||||
scheduleDetection()
|
||||
})
|
||||
}
|
||||
function stopCamera() {
|
||||
clearRestartCountdown()
|
||||
cancelAnimationFrame(animationFrame)
|
||||
animationFrame = 0
|
||||
stream?.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
if (video.value) video.value.srcObject = null
|
||||
cameraActive.value = false
|
||||
detectionLocked.value = false
|
||||
resetStability()
|
||||
result.value = null
|
||||
log('Camera stopped')
|
||||
}
|
||||
function scheduleDetection() {
|
||||
if (detectionLocked.value) return
|
||||
cancelAnimationFrame(animationFrame)
|
||||
animationFrame = requestAnimationFrame(detectionLoop)
|
||||
}
|
||||
async function detectionLoop() {
|
||||
if (!cameraActive.value || !video.value || !detector || detectionLocked.value) return
|
||||
if (!inferenceRunning) {
|
||||
inferenceRunning = true
|
||||
try {
|
||||
const next = await detector.detect(video.value)
|
||||
result.value = next
|
||||
if (next.timestamp !== lastResultTimestamp) {
|
||||
lastResultTimestamp = next.timestamp
|
||||
if (next.primary && next.primary.score >= 0.9) detectedFace.value = next.primary.image
|
||||
const state = next.quality.accepted ? 'accepted' : next.quality.reasons.join(',')
|
||||
if (state !== lastQualityState) { log(`Detection: ${state || 'waiting'}`); lastQualityState = state }
|
||||
updateStability(next)
|
||||
}
|
||||
} catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
inferenceRunning = false
|
||||
}
|
||||
}
|
||||
if (!detectionLocked.value) scheduleDetection()
|
||||
}
|
||||
function resetStability() {
|
||||
stableSince = 0
|
||||
stableBox = null
|
||||
stabilityProgress.value = 0
|
||||
}
|
||||
function updateStability(next: FaceDetectionResult) {
|
||||
const face = next.primary
|
||||
if (!next.quality.accepted || !face) {
|
||||
resetStability()
|
||||
return
|
||||
}
|
||||
|
||||
const previous = stableBox?.box
|
||||
const current = face.box
|
||||
const stable = previous &&
|
||||
Math.hypot(
|
||||
current.normalized.x + current.normalized.width / 2 - previous.normalized.x - previous.normalized.width / 2,
|
||||
current.normalized.y + current.normalized.height / 2 - previous.normalized.y - previous.normalized.height / 2
|
||||
) <= 0.035 &&
|
||||
Math.abs(current.normalized.width - previous.normalized.width) <= 0.04 &&
|
||||
Math.abs(current.normalized.height - previous.normalized.height) <= 0.04
|
||||
|
||||
if (!stable) {
|
||||
stableSince = performance.now()
|
||||
stableBox = face
|
||||
stabilityProgress.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - stableSince
|
||||
stabilityProgress.value = Math.min(1, elapsed / STABILITY_DURATION_MS)
|
||||
if (elapsed >= STABILITY_DURATION_MS) {
|
||||
detectedFace.value = face.image
|
||||
detectionLocked.value = true
|
||||
stabilityProgress.value = 1
|
||||
log('Stable face locked after 2 seconds')
|
||||
startRestartCountdown()
|
||||
}
|
||||
}
|
||||
function clearRestartCountdown() {
|
||||
if (restartTimer) window.clearInterval(restartTimer)
|
||||
restartTimer = 0
|
||||
restartCountdown.value = 10
|
||||
}
|
||||
function startRestartCountdown() {
|
||||
clearRestartCountdown()
|
||||
restartTimer = window.setInterval(() => {
|
||||
restartCountdown.value -= 1
|
||||
if (restartCountdown.value <= 0) detectAgain()
|
||||
}, 1000)
|
||||
}
|
||||
function detectAgain() {
|
||||
if (!cameraActive.value || !detector) return
|
||||
clearRestartCountdown()
|
||||
detectionLocked.value = false
|
||||
detectedFace.value = ''
|
||||
result.value = null
|
||||
resetStability()
|
||||
lastQualityState = ''
|
||||
log('Detection restarted')
|
||||
scheduleDetection()
|
||||
}
|
||||
function disposeDetector() {
|
||||
stopCamera()
|
||||
detector?.dispose()
|
||||
detector = null
|
||||
detectorReady.value = false
|
||||
diagnostics.value = null
|
||||
log('Detector disposed')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
portraitLayout.value = window.matchMedia('(orientation: portrait)').matches
|
||||
const config = await loadConfig()
|
||||
title.value = String(config.params.title || title.value)
|
||||
host.value = import.meta.env.VITE_CUTOS_BROKER_URL || String(config.params.host || 'localhost')
|
||||
lwaName.value = config.name
|
||||
lwaVersion.value = config.version
|
||||
void initializeRuntime()
|
||||
await startCamera()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearRestartCountdown()
|
||||
cancelAnimationFrame(animationFrame)
|
||||
stream?.getTracks().forEach(track => track.stop())
|
||||
detector?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<CutosTopbar :title="title" subtitle="Browser AI · CUTOS 4.0" :core-version="coreVersion" :lwa-name="lwaName" :lwa-version="lwaVersion" :connected="runtimeConnected" />
|
||||
<main>
|
||||
<section v-if="errorText" class="error-banner"><strong>Operation failed</strong><span>{{ errorText }}</span></section>
|
||||
<section class="section-heading">
|
||||
<div><span class="eyebrow">{{ host }}</span><h2>Local face detection</h2></div>
|
||||
<span class="dependency-tag">SDK: <strong>@cutos/ai-face-detector 4.0.0</strong></span>
|
||||
</section>
|
||||
|
||||
<section class="result-grid">
|
||||
<article class="result-card camera-card">
|
||||
<div class="result-header method-header"><div><h3>Camera preview</h3><code>FaceDetector.detect(video)</code></div><span class="state-tag" :class="{ online: result?.quality.accepted }">{{ qualityMessage }}</span></div>
|
||||
<div class="camera-stage" :class="{ empty: !cameraActive, portrait: portraitLayout }">
|
||||
<video ref="video" autoplay muted playsinline></video>
|
||||
<svg v-if="guideCircle" class="face-guide" :viewBox="guideCircle.viewBox" :preserveAspectRatio="portraitLayout ? 'xMidYMid slice' : 'none'" aria-hidden="true">
|
||||
<defs>
|
||||
<mask id="face-guide-mask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<circle :cx="guideCircle.cx" :cy="guideCircle.cy" :r="guideCircle.radius" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" class="guide-shade" mask="url(#face-guide-mask)" />
|
||||
<circle :cx="guideCircle.cx" :cy="guideCircle.cy" :r="guideCircle.radius" class="guide-ring" :class="{ accepted: result?.quality.accepted }" />
|
||||
<g v-if="result?.primary" class="face-marker" :class="{ accepted: result.quality.accepted }">
|
||||
<rect :x="result.primary.box.x" :y="result.primary.box.y" :width="result.primary.box.width" :height="result.primary.box.height" />
|
||||
<rect class="confidence-bg" :x="result.primary.box.x" :y="Math.max(0, result.primary.box.y - 34)" width="76" height="30" rx="4" />
|
||||
<text class="confidence-text" :x="result.primary.box.x + 8" :y="Math.max(22, result.primary.box.y - 12)">{{ Math.round(result.primary.score * 100) }}%</text>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="video-status">
|
||||
<section class="video-panel detector-panel">
|
||||
<header><strong>Detector</strong><b :class="{ pass: detectorReady }">{{ detectorReady ? 'READY' : 'OFF' }}</b></header>
|
||||
<dl>
|
||||
<div><dt>Initialized</dt><dd>{{ diagnostics?.initialized ? 'true' : 'false' }}</dd></div>
|
||||
<div><dt>Delegate</dt><dd>{{ diagnostics?.delegate || '—' }}</dd></div>
|
||||
<div><dt>Interval</dt><dd>{{ diagnostics?.minIntervalMs || 0 }} ms</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="video-panel gate-panel">
|
||||
<header><strong>Quality gate</strong><b :class="{ pass: detectionLocked }">{{ detectionLocked ? 'LOCKED' : result?.quality.accepted ? 'STABILIZING' : 'WAIT' }}</b></header>
|
||||
<dl>
|
||||
<div><dt>Single face</dt><dd :class="{ pass: result?.quality.singleFace }">{{ result?.quality.singleFace ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Confidence ≥ 0.90</dt><dd :class="{ pass: result?.quality.confident }">{{ result?.quality.confident ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Face size ≥ 12%</dt><dd :class="{ pass: result?.quality.largeEnough }">{{ result?.quality.largeEnough ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Face horizontally centred</dt><dd :class="{ pass: result?.quality.centered }">{{ result?.quality.centered ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Stable for 2 seconds</dt><dd :class="{ pass: detectionLocked }">{{ Math.round(stabilityProgress * 100) }}%</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<figure v-if="detectionLocked && detectedFace" class="locked-face">
|
||||
<img :src="detectedFace" alt="Locked detected face">
|
||||
<figcaption>Locked face</figcaption>
|
||||
</figure>
|
||||
<div v-if="!cameraActive" class="camera-empty">Camera is stopped</div>
|
||||
</div>
|
||||
<div class="card-body button-row"><button class="button primary" :disabled="cameraActive || !!busy" @click="startCamera">Start camera</button><button class="button primary" :disabled="!cameraActive || !detectionLocked" @click="detectAgain">{{ detectionLocked ? `Detect again (${restartCountdown}s)` : 'Detect again' }}</button><button class="button secondary" :disabled="!cameraActive" @click="stopCamera">Stop camera</button></div>
|
||||
</article>
|
||||
|
||||
<article class="result-card detection-result-card">
|
||||
<div class="result-header"><h3>Detection result</h3><code>FaceDetectionResult</code></div>
|
||||
<pre>{{ format(resultSummary) }}</pre>
|
||||
</article>
|
||||
|
||||
<article class="result-card activity-card">
|
||||
<div class="result-header"><h3>Activity</h3><code>Detector lifecycle and quality changes</code></div>
|
||||
<div class="activity-list"><div v-for="item in activity" :key="item.id"><time>{{ item.time }}</time><span>{{ item.message }}</span></div><p v-if="!activity.length">No activity</p></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import cutosLogo from '@/assets/cutos.png'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
coreVersion?: string
|
||||
lwaName?: string
|
||||
lwaVersion?: string
|
||||
connected: boolean
|
||||
}>(), {
|
||||
subtitle: 'CUTOS 4.0 LWA',
|
||||
coreVersion: '',
|
||||
lwaName: '',
|
||||
lwaVersion: ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img :src="cutosLogo" alt="CUTOS" />
|
||||
<div><h1>{{ title }}</h1><p>{{ subtitle }}</p></div>
|
||||
</div>
|
||||
<div class="status-cluster">
|
||||
<span class="version-tag">Core {{ coreVersion || '—' }}</span>
|
||||
<span class="version-tag">LWA {{ lwaName }} {{ lwaVersion || '—' }}</span>
|
||||
<span class="connection" :class="{ online: connected }"><i></i>{{ connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar{min-height:82px;padding:14px clamp(18px,4vw,52px);display:flex;align-items:center;justify-content:space-between;gap:24px;color:#f8fafb;background:#20262c;border-bottom:4px solid #16a394}.brand{display:flex;align-items:center;gap:15px;min-width:0}.brand img{width:42px;height:42px;object-fit:contain}.brand h1{margin:0;font-size:20px;line-height:1.2;font-weight:700}.brand p{margin:4px 0 0;font-size:12px;color:#aeb8bf}.status-cluster{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.version-tag,.connection{height:30px;display:inline-flex;align-items:center;padding:0 10px;font-size:12px;white-space:nowrap;border:1px solid #4b555d;border-radius:4px;background:#2b3339}.connection i{width:8px;height:8px;margin-right:7px;border-radius:50%;background:#e06b65}.connection.online i{background:#42c6a8;box-shadow:0 0 0 3px rgba(66,198,168,.14)}@media(max-width:640px){.topbar{align-items:flex-start;flex-direction:column;gap:12px}.status-cluster{justify-content:flex-start}}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
:root{font-family:Inter,"Segoe UI",Arial,sans-serif;color:#20262c;background:#eef1f3;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}button{font:inherit;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.48}.app-shell{min-height:100vh;background:#eef1f3}main{width:100%;padding:28px clamp(18px,2vw,36px) 32px}.error-banner{display:flex;gap:12px;margin-bottom:20px;padding:12px 16px;color:#7e2420;background:#fff0ef;border-left:4px solid #d54d47}.section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:15px}.section-heading h2{margin:3px 0 0;font-size:18px}.eyebrow{color:#64717a;font:11px "Cascadia Code",Consolas,monospace}.dependency-tag{padding:6px 9px;color:#5d6870;background:#f8fafb;border:1px solid #cbd3d8;border-radius:4px;font:11px "Cascadia Code",Consolas,monospace}.dependency-tag strong{color:#253038}.result-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.result-card{min-width:0;min-height:260px;background:#fff;border:1px solid #d8dee2;border-radius:6px;overflow:hidden}.camera-card,.full-row{grid-column:span 2}.camera-card{min-height:0}.compact-card{min-height:220px}.detector-card pre{max-height:105px}.result-header{min-height:66px;padding:13px 15px;border-bottom:1px solid #e4e8eb;background:#f8fafb}.result-header h3{margin:0 0 6px;font-size:14px}.result-header code{display:block;color:#68757d;font-size:11px;overflow-wrap:anywhere}.method-header{display:flex;align-items:center;justify-content:space-between;gap:14px}.state-tag{max-width:55%;padding:5px 8px;color:#8b3c37;background:#fff0ef;border-radius:3px;font:10px "Cascadia Code",Consolas,monospace;text-align:right}.state-tag.online{color:#087b71;background:#e8f7f4}.camera-stage{position:relative;width:min(100%,960px);margin:0 auto;background:#111820;line-height:0;overflow:hidden}.camera-stage.empty{min-height:280px}.camera-stage video{display:block;width:100%;height:auto}.face-guide{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.guide-shade{fill:rgba(7,12,17,.48)}.guide-ring{fill:none;stroke:#fff;stroke-width:3;stroke-dasharray:12 8;vector-effect:non-scaling-stroke;filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))}.guide-ring.accepted{stroke:#42c6a8;stroke-dasharray:none}.camera-empty{position:absolute;inset:0;display:grid;place-items:center;color:#9da8ae;font-size:13px;line-height:1.4}.face-box{position:absolute;border:2px solid #e06b65;box-shadow:0 0 0 1px rgba(0,0,0,.25)}.face-box.accepted{border-color:#42c6a8}.face-box span{position:absolute;left:-2px;bottom:100%;padding:3px 6px;color:#fff;background:#e06b65;font:10px "Cascadia Code",Consolas,monospace;line-height:1.4}.face-box.accepted span{background:#138b80}.card-body{padding:15px}.button-row{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.button{min-height:34px;padding:7px 13px;border:1px solid transparent;border-radius:4px;font-weight:650;font-size:12px}.button.primary{color:#fff;background:#138b80;border-color:#138b80}.button.secondary{color:#2d373d;background:#fff;border-color:#bfc8cd}pre{margin:0;padding:15px;max-height:280px;font:12px/1.55 "Cascadia Code",Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;overflow:auto}.quality-list{padding:8px 15px}.quality-list div{display:flex;justify-content:space-between;gap:15px;padding:11px 0;border-bottom:1px solid #edf0f2;font-size:12px}.quality-list div:last-child{border:0}.quality-list b{color:#8b3c37;font:10px "Cascadia Code",Consolas,monospace}.quality-list b.pass{color:#087b71}.face-preview{height:250px;display:grid;place-items:center;padding:15px;color:#77838b;font-size:12px;background:#f5f7f8}.face-preview img{max-width:100%;max-height:220px;object-fit:contain;border:1px solid #d8dee2}.activity-list{max-height:280px;overflow:auto}.activity-list div{display:grid;grid-template-columns:78px minmax(0,1fr);gap:10px;padding:9px 13px;border-bottom:1px solid #edf0f2;font-size:12px}.activity-list time{color:#77838b;font-size:11px}.activity-list p{padding:8px 15px;color:#77838b;font-size:12px}@media(max-width:700px){.section-heading{align-items:flex-start;flex-direction:column}.result-grid{grid-template-columns:1fr}.camera-card,.full-row{grid-column:auto}.camera-stage.empty{min-height:200px}.state-tag{max-width:50%}}
|
||||
.face-marker rect{fill:none;stroke:#e06b65;stroke-width:2;vector-effect:non-scaling-stroke}.face-marker.accepted rect{stroke:#42c6a8}.score-badge{position:absolute;top:10px;left:10px;padding:4px 7px;color:#fff;background:#e06b65;border-radius:3px;font:11px/1.4 "Cascadia Code",Consolas,monospace}.score-badge.accepted{background:#138b80}@media(orientation:portrait){.camera-stage.portrait{width:min(100%,calc(100vh - 300px));min-width:280px;aspect-ratio:1}.camera-stage.portrait video{width:100%;height:100%;object-fit:cover}.camera-stage.portrait.empty{min-height:0}}
|
||||
.face-marker .confidence-bg{fill:#e06b65;stroke:none}.face-marker.accepted .confidence-bg{fill:#138b80;stroke:none}.confidence-text{fill:#fff;font:600 20px "Cascadia Code",Consolas,monospace}.video-status{position:absolute;z-index:2;top:12px;left:12px;right:12px;display:grid;grid-template-columns:minmax(150px,220px) minmax(260px,360px);justify-content:space-between;gap:10px;pointer-events:none}.video-panel{padding:9px 11px;color:#f7fafb;background:rgba(16,23,28,.72);border:1px solid rgba(255,255,255,.22);border-radius:5px;backdrop-filter:blur(4px);font-size:11px;line-height:1.35}.video-panel header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}.video-panel strong{font-size:12px}.video-panel b{color:#f29a94;font:10px "Cascadia Code",Consolas,monospace}.video-panel b.pass{color:#55d8ba}.detector-panel>span{color:#c9d1d6;font:10px "Cascadia Code",Consolas,monospace}.gate-panel>div{display:grid;grid-template-columns:repeat(4,1fr);gap:5px}.gate-panel>div span{padding:3px 4px;color:#d99a96;text-align:center;background:rgba(196,71,64,.18);border-radius:3px;font:9px "Cascadia Code",Consolas,monospace}.gate-panel>div span.pass{color:#76dfc8;background:rgba(19,139,128,.24)}@media(max-width:700px){.video-status{grid-template-columns:1fr 1.5fr;top:8px;left:8px;right:8px;gap:6px}.video-panel{padding:7px 8px}.gate-panel>div{gap:3px}.gate-panel>div span{padding:3px 2px;font-size:8px}}
|
||||
.video-status{grid-template-columns:minmax(220px,310px) minmax(260px,360px)}.video-panel{background:rgba(16,23,28,.42);border-color:rgba(255,255,255,.3);text-shadow:0 1px 2px rgba(0,0,0,.8)}.video-panel dl{margin:0}.video-panel dl div{display:grid;grid-template-columns:minmax(82px,.8fr) minmax(0,1.5fr);gap:8px;padding:3px 0;border-top:1px solid rgba(255,255,255,.14)}.video-panel dt,.video-panel dd{margin:0;padding:0;border:0}.video-panel dt{color:#d5dde1}.video-panel dd{min-width:0;color:#fff;font:10px/1.35 "Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;text-align:right}.video-panel dd.pass{color:#70e0c6}.gate-panel dl div{grid-template-columns:minmax(0,1fr) 42px}@media(max-width:700px){.video-status{grid-template-columns:1fr 1fr}.video-panel dl div{grid-template-columns:1fr;padding:2px 0}.video-panel dd{text-align:left}.gate-panel dl div{grid-template-columns:minmax(0,1fr) 38px}.gate-panel dl dd{text-align:right}}
|
||||
.compact-face-card{min-height:0}.compact-face-card+.detection-result-card{min-height:0}.compact-face-card{grid-column:1}.detection-result-card{grid-column:2}.face-preview{height:auto;min-height:0;padding:10px}.face-preview img{display:block;width:100%;height:auto;max-height:190px;object-fit:contain}.activity-card{min-height:0}.activity-card .result-header{min-height:56px;padding-top:10px;padding-bottom:10px}.activity-list{max-height:140px}.activity-list div{padding-top:6px;padding-bottom:6px}@media(min-width:701px){.result-grid{grid-template-columns:minmax(220px,32%) minmax(0,68%)}}@media(max-width:700px){.compact-face-card,.detection-result-card{grid-column:auto}.face-preview img{width:auto;max-width:100%;max-height:170px}.activity-list{max-height:110px}}
|
||||
.video-panel{padding:6px 9px}.video-panel header{margin-bottom:3px}.video-panel dl div{padding:1px 0}.locked-face{position:absolute;z-index:3;right:12px;bottom:12px;margin:0;padding:5px;background:rgba(16,23,28,.46);border:1px solid rgba(255,255,255,.42);border-radius:5px;box-shadow:0 3px 12px rgba(0,0,0,.35)}.locked-face img{display:block;width:auto;height:auto;max-width:150px;max-height:180px;object-fit:contain}.locked-face figcaption{padding:4px 2px 0;color:#fff;font:9px/1.3 "Cascadia Code",Consolas,monospace;text-align:center}.detection-result-card,.activity-card{grid-column:auto;min-height:0}.detection-result-card pre,.activity-list{max-height:180px}@media(min-width:701px){.result-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.locked-face{right:8px;bottom:8px}.locked-face img{max-width:105px;max-height:130px}.detection-result-card pre,.activity-list{max-height:120px}}
|
||||
.gate-panel dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 10px}.gate-panel dl div{grid-template-columns:minmax(0,1fr) 38px}.video-panel dt{font-size:9px}.video-panel dd{font-size:9px}@media(max-width:700px){.gate-panel dl{grid-template-columns:1fr 1fr;gap:0 5px}.gate-panel dl div{grid-template-columns:1fr}.gate-panel dl dd{text-align:left}}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface LwaConfig {
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
params: {
|
||||
title: string
|
||||
host: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
const fallback: LwaConfig = {
|
||||
name: 'demo-ai-face-detector',
|
||||
version: '0.2.4',
|
||||
params: { title: 'CUTOS AI Face Detector', host: 'localhost' }
|
||||
}
|
||||
|
||||
function mergeQueryParams(config: LwaConfig) {
|
||||
const params = new URL(window.location.href).searchParams.get('params')
|
||||
if (!params) return config
|
||||
try {
|
||||
config.params = { ...config.params, ...JSON.parse(params) }
|
||||
} catch (error) {
|
||||
console.error('Invalid LWA params', error)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<LwaConfig> {
|
||||
try {
|
||||
const response = await fetch(new URL('config.json', window.location.href), { cache: 'no-cache' })
|
||||
if (!response.ok) throw new Error(`Failed to load config.json: ${response.status}`)
|
||||
return mergeQueryParams(await response.json() as LwaConfig)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return mergeQueryParams({ ...fallback, params: { ...fallback.params } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] },
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
publicDir: 'public',
|
||||
build: { target: ['chrome74'] },
|
||||
resolve: { alias: { '@': '/src' } },
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
|
||||
imports: ['vue'],
|
||||
vueTemplate: true,
|
||||
cache: true,
|
||||
dts: false
|
||||
})
|
||||
],
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
define: { global: 'globalThis' },
|
||||
target: 'es2015',
|
||||
supported: { bigint: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
# Demo AI Face Service
|
||||
|
||||
This LWA demonstrates the complete browser-to-Gateway face workflow:
|
||||
|
||||
1. `@cutos/ai-face-detector` captures one qualified face locally.
|
||||
2. `@cutos/gw-client` connects with the CUTOS device credentials obtained from `@cutos/core`.
|
||||
3. `@cutos/ai-face-service` creates the selected collection and invokes registration, lookup, listing, search, comparison, removal, and clearing.
|
||||
|
||||
During SDK development, the demo references `file:../sdk`. After `@cutos/ai-face-service` is published, replace it with `^4.0.0` and regenerate `package-lock.json`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cutos lwa build
|
||||
cutos lwa upload --upload-mode oss
|
||||
cutos lwa publish --device <device-id>
|
||||
```
|
||||
|
||||
The target CUTOS Runtime must provide device information including Gateway credentials (`id`, `gwi`, `token`, and `gwUrl`).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CUTOS AI Face Service</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "demo-ai-face-service",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"assets": "cutos-ai-face-detector-assets public/cutos-ai-face-detector",
|
||||
"dev": "npm run assets && vite",
|
||||
"build": "cutos lwa build",
|
||||
"build:app": "npm run assets && vue-tsc --noEmit && vite build",
|
||||
"package": "cutos lwa package",
|
||||
"validate": "cutos lwa validate",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cutos/ai-face-detector": "^4.0.1",
|
||||
"@cutos/ai-face-service": "file:../sdk",
|
||||
"@cutos/core": "^4.0.8",
|
||||
"@cutos/gw-client": "^1.0.9",
|
||||
"vue": "^3.4.31"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.0",
|
||||
"vite": "^5.3.4",
|
||||
"vue-tsc": "^2.0.26"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "demo-ai-face-service",
|
||||
"version": "0.2.0",
|
||||
"description": "CUTOS 4.0 Gateway AI face service demo",
|
||||
"params": {
|
||||
"title": "CUTOS AI Face Service",
|
||||
"host": "localhost"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { CoreAPI } from '@cutos/core'
|
||||
import { FaceDetector, type FaceDetectionResult } from '@cutos/ai-face-detector'
|
||||
import { FaceService, type FaceSearchResult } from '@cutos/ai-face-service'
|
||||
import { IPC } from '@cutos/gw-client'
|
||||
import { loadConfig } from './utils/config'
|
||||
|
||||
interface DeviceInfo {
|
||||
id: string
|
||||
gwi: string
|
||||
token: string
|
||||
gwUrl: string
|
||||
name?: string
|
||||
}
|
||||
interface ActivityItem { id: number; time: string; message: string }
|
||||
interface ServiceNotice { tone: 'success' | 'empty'; message: string }
|
||||
|
||||
const video = ref<HTMLVideoElement | null>(null)
|
||||
const title = ref('CUTOS AI Face Service')
|
||||
const host = ref('localhost')
|
||||
const lwaName = ref('demo-ai-face-service')
|
||||
const lwaVersion = ref('0.1.0')
|
||||
const coreVersion = ref('')
|
||||
const runtimeConnected = ref(false)
|
||||
const gatewayConnected = ref(false)
|
||||
const cameraActive = ref(false)
|
||||
const busy = ref('')
|
||||
const errorText = ref('')
|
||||
const latest = ref<FaceDetectionResult | null>(null)
|
||||
const capturedImage = ref('')
|
||||
const registeredId = ref('')
|
||||
const userId = ref('demo-user')
|
||||
const serviceResult = ref<unknown>(null)
|
||||
const serviceNotice = ref<ServiceNotice | null>(null)
|
||||
const activity = ref<ActivityItem[]>([])
|
||||
const autoCaptureProgress = ref(0)
|
||||
const restartCountdown = ref(10)
|
||||
|
||||
let detector: FaceDetector | null = null
|
||||
let gateway: IPC | null = null
|
||||
let faceService: FaceService | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let animationFrame = 0
|
||||
let inferenceRunning = false
|
||||
let stableSince = 0
|
||||
let stableBox: FaceDetectionResult['primary'] = null
|
||||
let restartTimer = 0
|
||||
|
||||
const STABILITY_DURATION_MS = 2000
|
||||
const FACE_MATCH_THRESHOLD = 0.5
|
||||
|
||||
const faceStatus = computed(() => {
|
||||
if (!latest.value?.primary) return 'No face detected'
|
||||
if (!latest.value.quality.accepted) return latest.value.quality.reasons.join(' · ')
|
||||
if (capturedImage.value) return `Face captured · restart in ${restartCountdown.value}s`
|
||||
if (stableSince) return `Hold still · ${Math.round(autoCaptureProgress.value * 100)}%`
|
||||
return 'Face ready to capture'
|
||||
})
|
||||
const serviceStatus = computed(() => gatewayConnected.value ? 'CONNECTED' : 'WAITING')
|
||||
const guideCircle = computed(() => {
|
||||
const frame = latest.value?.frame
|
||||
if (!frame) {
|
||||
return { viewBox: '0 0 100 100', cx: 50, cy: 50, radius: 43 }
|
||||
}
|
||||
return {
|
||||
viewBox: `0 0 ${frame.width} ${frame.height}`,
|
||||
cx: frame.width / 2,
|
||||
cy: frame.height / 2,
|
||||
radius: Math.min(frame.width, frame.height) * 0.43,
|
||||
}
|
||||
})
|
||||
function format(value: unknown) { return value == null ? 'No data' : JSON.stringify(value, null, 2) }
|
||||
function log(message: string) {
|
||||
activity.value.unshift({ id: Date.now() + Math.random(), time: new Date().toLocaleTimeString(), message })
|
||||
activity.value = activity.value.slice(0, 20)
|
||||
}
|
||||
async function run(name: string, action: () => Promise<void>) {
|
||||
busy.value = name
|
||||
errorText.value = ''
|
||||
serviceNotice.value = null
|
||||
try { await action() } catch (error) {
|
||||
errorText.value = error instanceof Error ? error.message : String(error)
|
||||
log(`${name}: ${errorText.value}`)
|
||||
} finally { busy.value = '' }
|
||||
}
|
||||
async function initializeRuntimeAndGateway() {
|
||||
await CoreAPI.init(host.value)
|
||||
runtimeConnected.value = CoreAPI.connected()
|
||||
coreVersion.value = CoreAPI.getVersion()
|
||||
const info = await CoreAPI.getDeviceInfo<DeviceInfo>()
|
||||
if (!info?.id || !info.gwi || !info.token || !info.gwUrl) {
|
||||
throw new Error('CUTOS device information does not include Gateway credentials.')
|
||||
}
|
||||
gateway = await new Promise<IPC>((resolve, reject) => {
|
||||
const ipc = new IPC(info.gwUrl, { gwi: info.gwi, username: info.id, token: info.token }, status => {
|
||||
if (status.status) resolve(ipc)
|
||||
else reject(new Error(status.msg || 'Gateway connection failed.'))
|
||||
})
|
||||
})
|
||||
faceService = new FaceService(gateway, { lwaName: lwaName.value })
|
||||
await faceService.init()
|
||||
gatewayConnected.value = true
|
||||
log(`Gateway connected for ${info.name || info.id}; face collection is ${lwaName.value}`)
|
||||
}
|
||||
async function initializeDetector() {
|
||||
if (detector) return
|
||||
detector = await FaceDetector.create({
|
||||
assetBaseUrl: new URL('cutos-ai-face-detector', window.location.href).toString(),
|
||||
preferredDelegate: 'gpu',
|
||||
allowCpuFallback: true,
|
||||
minIntervalMs: 180,
|
||||
minDetectionConfidence: 0.9,
|
||||
minFaceSizeRatio: 0.06,
|
||||
maxHorizontalCenterOffsetRatio: 0.2,
|
||||
guideAspectRatio: 1,
|
||||
})
|
||||
log('Local face detector initialized')
|
||||
}
|
||||
async function startCamera() {
|
||||
if (cameraActive.value) return
|
||||
await run('start camera', async () => {
|
||||
await initializeDetector()
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 720 },
|
||||
height: { ideal: 720 },
|
||||
aspectRatio: { ideal: 1 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
cameraActive.value = true
|
||||
await nextTick()
|
||||
if (!video.value) throw new Error('Video element is unavailable.')
|
||||
video.value.srcObject = stream
|
||||
await video.value.play()
|
||||
clearRestartCountdown()
|
||||
capturedImage.value = ''
|
||||
resetStability()
|
||||
detectLoop()
|
||||
log('Camera started')
|
||||
})
|
||||
}
|
||||
function stopCamera() {
|
||||
clearRestartCountdown()
|
||||
resetStability()
|
||||
cancelAnimationFrame(animationFrame)
|
||||
stream?.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
if (video.value) video.value.srcObject = null
|
||||
cameraActive.value = false
|
||||
latest.value = null
|
||||
log('Camera stopped')
|
||||
}
|
||||
async function detectLoop() {
|
||||
if (!cameraActive.value || !video.value || !detector || capturedImage.value) return
|
||||
if (!inferenceRunning) {
|
||||
inferenceRunning = true
|
||||
try {
|
||||
const next = await detector.detect(video.value)
|
||||
latest.value = next
|
||||
updateStability(next)
|
||||
}
|
||||
catch (error) { errorText.value = error instanceof Error ? error.message : String(error) }
|
||||
finally { inferenceRunning = false }
|
||||
}
|
||||
if (!capturedImage.value) animationFrame = requestAnimationFrame(detectLoop)
|
||||
}
|
||||
function resetStability() {
|
||||
stableSince = 0
|
||||
stableBox = null
|
||||
autoCaptureProgress.value = 0
|
||||
}
|
||||
function updateStability(next: FaceDetectionResult) {
|
||||
const face = next.primary
|
||||
if (!next.quality.accepted || !face) {
|
||||
resetStability()
|
||||
return
|
||||
}
|
||||
|
||||
const previous = stableBox?.box
|
||||
const current = face.box
|
||||
const stable = previous &&
|
||||
Math.hypot(
|
||||
current.normalized.x + current.normalized.width / 2 - previous.normalized.x - previous.normalized.width / 2,
|
||||
current.normalized.y + current.normalized.height / 2 - previous.normalized.y - previous.normalized.height / 2
|
||||
) <= 0.035 &&
|
||||
Math.abs(current.normalized.width - previous.normalized.width) <= 0.04 &&
|
||||
Math.abs(current.normalized.height - previous.normalized.height) <= 0.04
|
||||
|
||||
if (!stable) {
|
||||
stableSince = performance.now()
|
||||
stableBox = face
|
||||
autoCaptureProgress.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - stableSince
|
||||
autoCaptureProgress.value = Math.min(1, elapsed / STABILITY_DURATION_MS)
|
||||
if (elapsed >= STABILITY_DURATION_MS) {
|
||||
capturedImage.value = face.image
|
||||
autoCaptureProgress.value = 1
|
||||
serviceResult.value = { captured: true, automatic: true, score: face.score, timestamp: new Date().toISOString() }
|
||||
log('Stable face captured after 2 seconds')
|
||||
startRestartCountdown()
|
||||
}
|
||||
}
|
||||
function clearRestartCountdown() {
|
||||
if (restartTimer) window.clearInterval(restartTimer)
|
||||
restartTimer = 0
|
||||
restartCountdown.value = 10
|
||||
}
|
||||
function startRestartCountdown() {
|
||||
clearRestartCountdown()
|
||||
restartTimer = window.setInterval(() => {
|
||||
restartCountdown.value -= 1
|
||||
if (restartCountdown.value <= 0) detectAgain()
|
||||
}, 1000)
|
||||
}
|
||||
function detectAgain() {
|
||||
if (!cameraActive.value || !detector) return
|
||||
clearRestartCountdown()
|
||||
capturedImage.value = ''
|
||||
latest.value = null
|
||||
resetStability()
|
||||
log('Detection restarted')
|
||||
animationFrame = requestAnimationFrame(detectLoop)
|
||||
}
|
||||
function requireServiceAndImage(): FaceService {
|
||||
if (!faceService || !gatewayConnected.value) throw new Error('Gateway face service is not connected.')
|
||||
if (!capturedImage.value) throw new Error('Capture a qualified face first.')
|
||||
return faceService
|
||||
}
|
||||
function requireService(): FaceService {
|
||||
if (!faceService || !gatewayConnected.value) throw new Error('Gateway face service is not connected.')
|
||||
return faceService
|
||||
}
|
||||
async function searchCapturedFace(service: FaceService): Promise<FaceSearchResult | null> {
|
||||
try {
|
||||
return await service.search(capturedImage.value)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /no match found/i.test(error.message)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async function register() {
|
||||
await run('register', async () => {
|
||||
const service = requireServiceAndImage()
|
||||
try {
|
||||
registeredId.value = await service.register(capturedImage.value, {
|
||||
userId: userId.value,
|
||||
saveImage: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !/USER_ID_ALREADY_EXISTS/i.test(error.message)) throw error
|
||||
const existing = await service.getByUserId(userId.value)
|
||||
if (!existing) throw error
|
||||
registeredId.value = existing.id
|
||||
serviceResult.value = existing
|
||||
serviceNotice.value = {
|
||||
tone: 'success',
|
||||
message: `User ID ${userId.value} is already registered. Face ID: ${existing.id}`,
|
||||
}
|
||||
log(`User ${userId.value} is already registered as ${existing.id}`)
|
||||
return
|
||||
}
|
||||
serviceResult.value = { registeredId: registeredId.value, userId: userId.value, imageSaved: true }
|
||||
serviceNotice.value = { tone: 'success', message: `Registered successfully. Face ID: ${registeredId.value}` }
|
||||
log(`Registered face ${registeredId.value} for ${userId.value}`)
|
||||
})
|
||||
}
|
||||
async function search() {
|
||||
await run('search', async () => {
|
||||
const service = requireServiceAndImage()
|
||||
const match = await searchCapturedFace(service)
|
||||
if (match && match.score >= FACE_MATCH_THRESHOLD) {
|
||||
registeredId.value = match.id
|
||||
serviceResult.value = match
|
||||
serviceNotice.value = {
|
||||
tone: 'success',
|
||||
message: `Face found. ID: ${match.id}, score: ${(match.score * 100).toFixed(1)}%`,
|
||||
}
|
||||
log(`Search match ${match.id}, score ${match.score.toFixed(3)}`)
|
||||
} else {
|
||||
registeredId.value = ''
|
||||
serviceResult.value = match
|
||||
serviceNotice.value = { tone: 'empty', message: 'No registered face matches the captured image.' }
|
||||
log('No matching face found')
|
||||
}
|
||||
})
|
||||
}
|
||||
async function unregister() {
|
||||
await run('unregister', async () => {
|
||||
const service = requireService()
|
||||
if (!registeredId.value) throw new Error('No registered face id is available.')
|
||||
const id = await service.unregister(registeredId.value)
|
||||
serviceResult.value = { unregisteredId: id }
|
||||
registeredId.value = ''
|
||||
serviceNotice.value = { tone: 'success', message: `Face ${id} was unregistered.` }
|
||||
log(`Unregistered face ${id}`)
|
||||
})
|
||||
}
|
||||
async function getByUserId() {
|
||||
await run('get by user id', async () => {
|
||||
const service = requireService()
|
||||
const record = await service.getByUserId(userId.value, { includeImage: true })
|
||||
serviceResult.value = record
|
||||
if (record) registeredId.value = record.id
|
||||
else registeredId.value = ''
|
||||
serviceNotice.value = record
|
||||
? { tone: 'success', message: `Face found. ID: ${record.id}` }
|
||||
: { tone: 'empty', message: `No face is registered for ${userId.value}.` }
|
||||
log(record ? `Loaded face ${record.id} for ${userId.value}` : `No face registered for ${userId.value}`)
|
||||
})
|
||||
}
|
||||
async function listFaces() {
|
||||
await run('list faces', async () => {
|
||||
const service = requireService()
|
||||
const page = await service.list({ limit: 20 })
|
||||
serviceResult.value = page
|
||||
log(`Listed ${page.items.length} face records`)
|
||||
})
|
||||
}
|
||||
async function clearFaces() {
|
||||
if (!window.confirm(`Clear every face registered for ${lwaName.value}?`)) return
|
||||
await run('clear faces', async () => {
|
||||
const service = requireService()
|
||||
await service.clear()
|
||||
registeredId.value = ''
|
||||
serviceResult.value = { cleared: true }
|
||||
serviceNotice.value = { tone: 'success', message: 'All registered faces were cleared.' }
|
||||
log(`Cleared face collection ${lwaName.value}`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const config = await loadConfig()
|
||||
title.value = String(config.params?.title || title.value)
|
||||
host.value = import.meta.env.VITE_CUTOS_BROKER_URL || String(config.params?.host || host.value)
|
||||
lwaName.value = String(config.name || lwaName.value)
|
||||
lwaVersion.value = String(config.version || lwaVersion.value)
|
||||
await run('initialize Gateway service', initializeRuntimeAndGateway)
|
||||
await startCamera()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearRestartCountdown()
|
||||
stopCamera()
|
||||
detector?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="topbar"><div class="brand"><span class="brand-mark">CUTOS</span><div><h1>{{ title }}</h1><p>Gateway AI · CUTOS 4.0</p></div></div><div class="tags"><span>Core {{ coreVersion || '—' }}</span><span>LWA {{ lwaName }} {{ lwaVersion }}</span><b :class="{ online: runtimeConnected }">{{ runtimeConnected ? 'Runtime connected' : 'Runtime offline' }}</b></div></header>
|
||||
<main>
|
||||
<section v-if="errorText" class="error-banner"><strong>Operation failed</strong><span>{{ errorText }}</span></section>
|
||||
<section class="heading"><div><small>{{ host }}</small><h2>Gateway face recognition service</h2></div><span class="dependency">Detector: <b>@cutos/ai-face-detector 4.0.1</b> · Service: <b>@cutos/ai-face-service 4.0.0</b></span></section>
|
||||
<section class="grid">
|
||||
<article class="card camera-card">
|
||||
<header><div><h3>Capture face</h3><code>FaceDetector.detect(video)</code></div><b class="state" :class="{ ready: latest?.quality.accepted }">{{ faceStatus }}</b></header>
|
||||
<div class="camera">
|
||||
<video ref="video" autoplay muted playsinline></video>
|
||||
<svg v-if="guideCircle" class="face-guide" :viewBox="guideCircle.viewBox" preserveAspectRatio="none" aria-hidden="true">
|
||||
<defs><mask id="face-guide-mask"><rect width="100%" height="100%" fill="white" /><circle :cx="guideCircle.cx" :cy="guideCircle.cy" :r="guideCircle.radius" fill="black" /></mask></defs>
|
||||
<rect width="100%" height="100%" class="guide-shade" mask="url(#face-guide-mask)" />
|
||||
<circle :cx="guideCircle.cx" :cy="guideCircle.cy" :r="guideCircle.radius" class="guide-ring" :class="{ ready: latest?.quality.accepted }" />
|
||||
<g v-if="latest?.primary" class="face-marker" :class="{ accepted: latest.quality.accepted }">
|
||||
<rect :x="latest.primary.box.x" :y="latest.primary.box.y" :width="latest.primary.box.width" :height="latest.primary.box.height" />
|
||||
<rect class="confidence-bg" :x="latest.primary.box.x" :y="Math.max(0, latest.primary.box.y - 34)" width="76" height="30" rx="4" />
|
||||
<text class="confidence-text" :x="latest.primary.box.x + 8" :y="Math.max(22, latest.primary.box.y - 12)">{{ Math.round(latest.primary.score * 100) }}%</text>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="video-status">
|
||||
<section class="video-panel gate-panel">
|
||||
<header><strong>Quality gate</strong><b :class="{ pass: Boolean(capturedImage) }">{{ capturedImage ? 'LOCKED' : latest?.quality.accepted ? 'STABILIZING' : 'WAIT' }}</b></header>
|
||||
<dl>
|
||||
<div><dt>Single face</dt><dd :class="{ pass: latest?.quality.singleFace }">{{ latest?.quality.singleFace ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Confidence ≥ 0.90</dt><dd :class="{ pass: latest?.quality.confident }">{{ latest?.quality.confident ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Face size ≥ 6%</dt><dd :class="{ pass: latest?.quality.largeEnough }">{{ latest?.quality.largeEnough ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Face horizontally centred</dt><dd :class="{ pass: latest?.quality.centered }">{{ latest?.quality.centered ? 'PASS' : 'WAIT' }}</dd></div>
|
||||
<div><dt>Stable for 2 seconds</dt><dd :class="{ pass: Boolean(capturedImage) }">{{ Math.round(autoCaptureProgress * 100) }}%</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<figure v-if="capturedImage" class="locked-face"><img :src="capturedImage" alt="Locked detected face"><figcaption>Locked face</figcaption></figure>
|
||||
<p v-if="!cameraActive">Camera is stopped</p>
|
||||
</div>
|
||||
<footer><button class="primary" :disabled="cameraActive || !!busy" @click="startCamera">Start camera</button><button class="primary" :disabled="!cameraActive || !capturedImage || !!busy" @click="detectAgain">{{ capturedImage ? `Capture again (${restartCountdown}s)` : 'Capture again' }}</button><button :disabled="!cameraActive" @click="stopCamera">Stop camera</button></footer>
|
||||
</article>
|
||||
<article class="card service-card"><header><div><h3>Face service</h3><code>Gateway request / response</code></div><b class="state" :class="{ ready: gatewayConnected }">{{ serviceStatus }}</b></header><div class="identity-fields"><label><span>User ID</span><input v-model.trim="userId" :disabled="!!busy" autocomplete="off"></label><label><span>Face ID</span><input :value="registeredId" readonly placeholder="Not assigned"></label></div><div class="service-actions"><button class="primary" :disabled="!!busy || !capturedImage || !userId" @click="register">Register</button><button :disabled="!!busy || !userId" @click="getByUserId">Get user</button><button :disabled="!!busy" @click="listFaces">List</button><button :disabled="!!busy" @click="clearFaces">Clear</button><button class="primary" :disabled="!!busy || !capturedImage" @click="search">Search</button><button :disabled="!!busy || !registeredId" @click="unregister">Unregister</button></div><p v-if="serviceNotice" class="service-notice" :class="serviceNotice.tone">{{ serviceNotice.message }}</p><dl><div><dt>Captured image</dt><dd>{{ capturedImage ? 'Ready' : 'Waiting' }}</dd></div></dl><pre>{{ format(serviceResult) }}</pre></article>
|
||||
<article class="card activity-card"><header><h3>Activity</h3><code>Gateway, detector, and service actions</code></header><div class="activity"><div v-for="item in activity" :key="item.id"><time>{{ item.time }}</time><span>{{ item.message }}</span></div><p v-if="!activity.length">No activity</p></div></article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
.camera { width: min(100%, 720px); aspect-ratio: 1; }
|
||||
.camera video { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.camera .face-guide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.camera .guide-shade { fill: rgba(7, 12, 17, 0.56); }
|
||||
|
||||
.camera .guide-ring {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 12 8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.camera .guide-ring.ready { stroke: #42c6a8; stroke-dasharray: none; }
|
||||
|
||||
.face-marker rect { fill: none; stroke: #e06b65; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.face-marker.accepted rect { stroke: #42c6a8; }
|
||||
.face-marker .confidence-bg { fill: #e06b65; stroke: none; }
|
||||
.face-marker.accepted .confidence-bg { fill: #138b80; stroke: none; }
|
||||
.confidence-text { fill: #fff; font: 600 20px "Cascadia Code", Consolas, monospace; }
|
||||
|
||||
.video-status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 310px) minmax(240px, 320px);
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-panel {
|
||||
color: #f7fafb;
|
||||
background: rgba(16, 23, 28, 0.42);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 5px;
|
||||
backdrop-filter: blur(4px);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.video-panel { padding: 6px 9px; font-size: 11px; line-height: 1.35; }
|
||||
.video-panel header { min-height: 0; padding: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 3px; background: transparent; border: 0; }
|
||||
.video-panel strong { font-size: 12px; }
|
||||
.video-panel b { color: #f29a94; font: 10px "Cascadia Code", Consolas, monospace; }
|
||||
.video-panel b.pass { color: #55d8ba; }
|
||||
.gate-panel { grid-column: 2; }
|
||||
.video-panel dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 10px; margin: 0; }
|
||||
.video-panel dl div { display: grid; grid-template-columns: minmax(0, 1fr) 38px; gap: 8px; padding: 1px 0; border-top: 1px solid rgba(255, 255, 255, 0.14); }
|
||||
.video-panel dt,
|
||||
.video-panel dd { margin: 0; padding: 0; border: 0; }
|
||||
.video-panel dt { color: #d5dde1; font-size: 9px; }
|
||||
.video-panel dd { min-width: 0; color: #fff; font: 9px/1.35 "Cascadia Code", Consolas, monospace; overflow-wrap: anywhere; text-align: right; }
|
||||
.video-panel dd.pass { color: #70e0c6; }
|
||||
|
||||
.locked-face { position: absolute; z-index: 3; right: 12px; bottom: 12px; margin: 0; padding: 5px; background: rgba(16, 23, 28, 0.46); border: 1px solid rgba(255, 255, 255, 0.42); border-radius: 5px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.35); }
|
||||
.locked-face img { display: block; width: auto; height: auto; max-width: 150px; max-height: 180px; object-fit: contain; }
|
||||
.locked-face figcaption { padding: 4px 2px 0; color: #fff; font: 9px/1.3 "Cascadia Code", Consolas, monospace; text-align: center; }
|
||||
|
||||
.identity-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(220px, 1.2fr);
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #edf0f2;
|
||||
}
|
||||
|
||||
.identity-fields label { display: grid; gap: 6px; min-width: 0; }
|
||||
.identity-fields span { color: #68757d; font-size: 11px; font-weight: 600; }
|
||||
.identity-fields input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: #253038;
|
||||
background: #fff;
|
||||
border: 1px solid #bcc6cc;
|
||||
border-radius: 4px;
|
||||
font: 12px "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
.identity-fields input[readonly] { color: #4e5b63; background: #f5f7f8; }
|
||||
|
||||
.service-notice {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #edf0f2;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.service-notice.success { color: #087b71; background: #e8f7f4; }
|
||||
.service-notice.empty { color: #8b3c37; background: #fff0ef; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.identity-fields { grid-template-columns: 1fr; }
|
||||
.video-status { top: 8px; left: 8px; right: 8px; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.video-panel { padding: 7px 8px; }
|
||||
.video-panel dl { gap: 0 5px; }
|
||||
.video-panel dl div { grid-template-columns: 1fr; gap: 1px; padding: 2px 0; }
|
||||
.video-panel dd { text-align: left; }
|
||||
.locked-face { right: 8px; bottom: 8px; }
|
||||
.locked-face img { max-width: 105px; max-height: 130px; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
declare module '@cutos/gw-client' {
|
||||
export class IPC {
|
||||
gwi: string
|
||||
constructor(
|
||||
gwUrl: string,
|
||||
options: { gwi: string; username: string; token: string; ext?: Record<string, unknown> },
|
||||
callback?: (status: { status: boolean; msg?: string }) => void,
|
||||
)
|
||||
publishRequest(
|
||||
topic: string,
|
||||
body: unknown,
|
||||
callback?: (response: unknown, error?: unknown) => void,
|
||||
userContext?: Record<string, unknown>,
|
||||
options?: unknown,
|
||||
): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import './camera-overlay.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,"Segoe UI",Arial,sans-serif;color:#20262c;background:#eef1f3;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}button{padding:8px 12px;color:#2d373d;background:#fff;border:1px solid #bcc6cc;border-radius:4px;font:600 12px inherit;cursor:pointer}button.primary{color:#fff;background:#138b80;border-color:#138b80}button:disabled{cursor:not-allowed;opacity:.46}.app-shell{min-height:100vh}.topbar{min-height:82px;padding:14px clamp(18px,4vw,52px);display:flex;align-items:center;justify-content:space-between;gap:24px;color:#f8fafb;background:#20262c;border-bottom:4px solid #16a394}.brand{display:flex;align-items:center;gap:15px}.brand-mark{display:grid;place-items:center;width:46px;height:46px;color:#fff;border:1px solid #6ccbbb;border-radius:50%;font-size:10px;font-weight:800;letter-spacing:.7px}.brand h1{margin:0;font-size:20px}.brand p{margin:4px 0 0;color:#aeb8bf;font-size:12px}.tags{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.tags span,.tags b{padding:7px 9px;background:#2b3339;border:1px solid #4b555d;border-radius:4px;font:11px "Cascadia Code",Consolas,monospace}.tags b{color:#eb9b96}.tags b.online{color:#55d8ba}main{padding:28px clamp(18px,2vw,36px) 32px}.error-banner{display:flex;gap:12px;margin-bottom:18px;padding:12px 15px;color:#7e2420;background:#fff0ef;border-left:4px solid #d54d47;font-size:13px}.heading{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:15px}.heading small{color:#64717a;font:11px "Cascadia Code",Consolas,monospace}.heading h2{margin:3px 0 0;font-size:18px}.dependency{padding:6px 9px;color:#5d6870;background:#f8fafb;border:1px solid #cbd3d8;border-radius:4px;font:11px "Cascadia Code",Consolas,monospace}.dependency b{color:#253038}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.card{min-width:0;background:#fff;border:1px solid #d8dee2;border-radius:6px;overflow:hidden}.card header{min-height:64px;padding:13px 15px;display:flex;justify-content:space-between;align-items:center;gap:12px;background:#f8fafb;border-bottom:1px solid #e4e8eb}.card h3{margin:0 0 6px;font-size:14px}.card code{display:block;color:#68757d;font-size:11px;overflow-wrap:anywhere}.camera-card{grid-column:span 2}.state{max-width:52%;padding:5px 8px;color:#8b3c37;background:#fff0ef;border-radius:3px;font:10px "Cascadia Code",Consolas,monospace;text-align:right}.state.ready{color:#087b71;background:#e8f7f4}.camera{position:relative;width:min(100%,900px);margin:auto;background:#111820;line-height:0;overflow:hidden}.camera video{display:block;width:100%;height:auto}.camera>p{min-height:280px;margin:0;display:grid;place-items:center;color:#aab4ba;font-size:13px;line-height:1.4}.face-box{position:absolute;border:2px solid #e06b65}.face-box.ready{border-color:#42c6a8}.face-box span{position:absolute;left:-2px;bottom:100%;padding:3px 6px;color:#fff;background:#e06b65;font:10px "Cascadia Code",Consolas,monospace;line-height:1.4}.face-box.ready span{background:#138b80}.card footer,.service-actions{display:flex;gap:9px;flex-wrap:wrap;padding:14px}.service-actions{border-bottom:1px solid #edf0f2}.service-card dl{margin:0;padding:0 15px}.service-card dl div{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #edf0f2;font-size:12px}.service-card dt{color:#68757d}.service-card dd{margin:0;max-width:60%;font-family:"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;text-align:right}pre{max-height:180px;margin:0;padding:14px;overflow:auto;font:12px/1.55 "Cascadia Code",Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview{min-height:210px;padding:12px;display:grid;place-items:center;background:#f5f7f8}.preview img{max-width:100%;max-height:260px;object-fit:contain;border:1px solid #d8dee2}.preview p{color:#77838b;font-size:12px;text-align:center}.quality-gates{padding:8px 15px}.quality-gates div{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:8px 0;border-bottom:1px solid #edf0f2;font-size:12px}.quality-gates div:last-child{border-bottom:0}.quality-gates b{color:#9a443e;font:10px "Cascadia Code",Consolas,monospace}.quality-gates b.pass{color:#087b71}.activity{max-height:265px;overflow:auto}.activity div{display:grid;grid-template-columns:78px minmax(0,1fr);gap:10px;padding:9px 13px;border-bottom:1px solid #edf0f2;font-size:12px}.activity time{color:#77838b;font-size:11px}.activity p{padding:8px 15px;color:#77838b;font-size:12px}@media(max-width:700px){.topbar,.heading{align-items:flex-start;flex-direction:column;gap:12px}.tags{justify-content:flex-start}.grid{grid-template-columns:1fr}.camera-card{grid-column:auto}.state{max-width:50%}.camera>p{min-height:200px}}
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function loadConfig(): Promise<Record<string, any>> {
|
||||
const response = await fetch('./config.json', { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`Unable to load config.json: ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: { extend: {} },
|
||||
plugins: []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
publicDir: 'public',
|
||||
build: { target: ['chrome74'] },
|
||||
resolve: { alias: { '@': '/src' } },
|
||||
plugins: [vue()],
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
define: { global: 'globalThis' },
|
||||
target: 'es2015',
|
||||
supported: { bigint: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user