django-camera-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. django_camera_kit/__init__.py +1 -0
  2. django_camera_kit/apps.py +7 -0
  3. django_camera_kit/kyc/__init__.py +0 -0
  4. django_camera_kit/kyc/apps.py +8 -0
  5. django_camera_kit/kyc/conf.py +24 -0
  6. django_camera_kit/kyc/embeddings.py +39 -0
  7. django_camera_kit/kyc/migrations/0001_initial.py +85 -0
  8. django_camera_kit/kyc/migrations/__init__.py +0 -0
  9. django_camera_kit/kyc/models.py +67 -0
  10. django_camera_kit/kyc/serializers.py +23 -0
  11. django_camera_kit/kyc/static/django_camera_kit/kyc/css/kyc_capture.css +59 -0
  12. django_camera_kit/kyc/static/django_camera_kit/kyc/js/face_kit.js +116 -0
  13. django_camera_kit/kyc/static/django_camera_kit/kyc/js/kyc_capture.js +347 -0
  14. django_camera_kit/kyc/static/django_camera_kit/kyc/vendor/face_detection_yunet_2023mar.onnx +0 -0
  15. django_camera_kit/kyc/templates/django_camera_kit/kyc/widgets/kyc_verification.html +7 -0
  16. django_camera_kit/kyc/throttling.py +20 -0
  17. django_camera_kit/kyc/urls.py +9 -0
  18. django_camera_kit/kyc/views.py +65 -0
  19. django_camera_kit/kyc/widgets.py +39 -0
  20. django_camera_kit/static/django_camera_kit/css/camera_kit.css +97 -0
  21. django_camera_kit/static/django_camera_kit/js/camera_kit.js +213 -0
  22. django_camera_kit/static/django_camera_kit/js/doc_scan.js +367 -0
  23. django_camera_kit/static/django_camera_kit/vendor/README.md +6 -0
  24. django_camera_kit/static/django_camera_kit/vendor/jspdf.umd.min.js +373 -0
  25. django_camera_kit/static/django_camera_kit/vendor/opencv.js +0 -0
  26. django_camera_kit/templates/django_camera_kit/widgets/document_scanner.html +6 -0
  27. django_camera_kit/widgets.py +25 -0
  28. django_camera_kit-0.1.0.dist-info/METADATA +129 -0
  29. django_camera_kit-0.1.0.dist-info/RECORD +32 -0
  30. django_camera_kit-0.1.0.dist-info/WHEEL +5 -0
  31. django_camera_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
  32. django_camera_kit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,347 @@
1
+ (function (window, document) {
2
+ "use strict";
3
+
4
+ var ALIGN_STREAK = 8; // consecutive frames with a detected face before we start the liveness challenge
5
+ var MOVE_THRESHOLD_RATIO = 0.12; // horizontal head movement required, as a fraction of frame width
6
+
7
+ function getCookie(name) {
8
+ var match = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)");
9
+ return match ? decodeURIComponent(match[2]) : null;
10
+ }
11
+
12
+ function init() {
13
+ document.querySelectorAll(".camera-kit-kyc").forEach(function (container) {
14
+ if (container.dataset.camerakitBound) return;
15
+ container.dataset.camerakitBound = "true";
16
+ bindWidget(container);
17
+ });
18
+ }
19
+
20
+ function bindWidget(container) {
21
+ var input = container.querySelector("input[type=hidden]");
22
+ var trigger = container.querySelector(".camera-kit-kyc-trigger");
23
+ var statusEl = container.querySelector(".camera-kit-kyc-status");
24
+ if (!input || !trigger) return;
25
+
26
+ trigger.addEventListener("click", function () {
27
+ openFlow(container, input, statusEl);
28
+ });
29
+ }
30
+
31
+ function openFlow(container, input, statusEl) {
32
+ var verifyUrl = container.dataset.verifyUrl;
33
+ if (!verifyUrl) {
34
+ statusEl.textContent = "django-camera-kit: aucun verify_url configuré sur ce widget.";
35
+ return;
36
+ }
37
+
38
+ var session = {
39
+ stream: null,
40
+ phase: "align",
41
+ alignStreak: 0,
42
+ challengeStartX: null,
43
+ challengeMaxDelta: 0,
44
+ idBlob: null,
45
+ selfieBlob: null,
46
+ livenessPassed: false,
47
+ running: true,
48
+ };
49
+
50
+ var overlay = buildOverlay();
51
+ document.body.appendChild(overlay.root);
52
+
53
+ FaceKit.ready(); // kick off model loading in the background, in parallel with camera permission
54
+
55
+ enterIdStep(session, overlay);
56
+
57
+ overlay.captureIdBtn.addEventListener("click", function () {
58
+ captureId(session, overlay);
59
+ });
60
+
61
+ overlay.restartBtn.addEventListener("click", function () {
62
+ enterIdStep(session, overlay);
63
+ });
64
+
65
+ overlay.manualSelfieBtn.addEventListener("click", function () {
66
+ captureSelfie(session, overlay, false);
67
+ });
68
+
69
+ overlay.retakeSelfieBtn.addEventListener("click", function () {
70
+ enterSelfieStep(session, overlay);
71
+ });
72
+
73
+ overlay.sendBtn.addEventListener("click", function () {
74
+ submit(session, overlay, input, verifyUrl, statusEl);
75
+ });
76
+
77
+ overlay.closeBtn.addEventListener("click", function () {
78
+ closeOverlay(session, overlay);
79
+ });
80
+
81
+ overlay.cancelBtns.forEach(function (btn) {
82
+ btn.addEventListener("click", function () {
83
+ closeOverlay(session, overlay);
84
+ });
85
+ });
86
+ }
87
+
88
+ function enterIdStep(session, overlay) {
89
+ overlay.idVideoWrapper.appendChild(overlay.video);
90
+ showStep(overlay, "id");
91
+ switchCamera(session, overlay, "environment");
92
+ }
93
+
94
+ function enterSelfieStep(session, overlay) {
95
+ overlay.selfieVideoWrapper.appendChild(overlay.video);
96
+ session.phase = "align";
97
+ session.alignStreak = 0;
98
+ session.challengeStartX = null;
99
+ session.challengeMaxDelta = 0;
100
+ showStep(overlay, "selfie");
101
+ switchCamera(session, overlay, "user").then(function () {
102
+ trackSelfie(session, overlay);
103
+ });
104
+ }
105
+
106
+ function switchCamera(session, overlay, facingMode) {
107
+ CameraKit.stopStream(session.stream);
108
+ return CameraKit.openStream({ video: { facingMode: { ideal: facingMode } }, audio: false })
109
+ .then(function (stream) {
110
+ session.stream = stream;
111
+ overlay.video.srcObject = stream;
112
+ return overlay.video.play();
113
+ })
114
+ .catch(function (err) {
115
+ overlay.status.textContent = "Impossible d'accéder à la caméra : " + err.message;
116
+ });
117
+ }
118
+
119
+ function captureId(session, overlay) {
120
+ var frame = CameraKit.captureFrame(overlay.video);
121
+ CameraKit.canvasToBlob(frame).then(function (blob) {
122
+ session.idBlob = blob;
123
+ overlay.idPreview.src = URL.createObjectURL(blob);
124
+ enterSelfieStep(session, overlay);
125
+ });
126
+ }
127
+
128
+ function trackSelfie(session, overlay) {
129
+ if (!session.running || session.phase === "done") return;
130
+
131
+ if (overlay.video.readyState === overlay.video.HAVE_ENOUGH_DATA) {
132
+ var frame = CameraKit.captureFrame(overlay.video);
133
+ var detection = FaceKit.detectFace(frame);
134
+ updateMotionState(session, overlay, frame, detection);
135
+ }
136
+
137
+ window.requestAnimationFrame(function () {
138
+ trackSelfie(session, overlay);
139
+ });
140
+ }
141
+
142
+ // Liveness challenge: track how far the detected face's horizontal
143
+ // center moves once the user is asked to turn their head, instead of a
144
+ // blink. YuNet only returns 5 point landmarks (eye/nose/mouth centers),
145
+ // not full eyelid contours, so a real eye-aspect-ratio blink signal
146
+ // isn't available — head movement is a robust substitute that doesn't
147
+ // depend on eye-region precision at all.
148
+ function updateMotionState(session, overlay, frame, detection) {
149
+ if (!detection) {
150
+ overlay.hint.textContent = "Aucun visage détecté — centrez votre visage dans le cadre.";
151
+ session.alignStreak = 0;
152
+ return;
153
+ }
154
+
155
+ if (session.phase === "align") {
156
+ overlay.hint.textContent = "Restez immobile, centrez votre visage…";
157
+ session.alignStreak++;
158
+ if (session.alignStreak >= ALIGN_STREAK) {
159
+ session.phase = "challenge";
160
+ session.challengeStartX = detection.centerX;
161
+ session.challengeMaxDelta = 0;
162
+ overlay.hint.textContent = "Tournez légèrement la tête, à gauche ou à droite.";
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (session.phase === "challenge") {
168
+ var delta = Math.abs(detection.centerX - session.challengeStartX);
169
+ session.challengeMaxDelta = Math.max(session.challengeMaxDelta, delta);
170
+
171
+ if (session.challengeMaxDelta >= frame.width * MOVE_THRESHOLD_RATIO) {
172
+ session.phase = "done";
173
+ finishChallenge(session, overlay, frame, true);
174
+ }
175
+ }
176
+ }
177
+
178
+ function finishChallenge(session, overlay, frame, livenessPassed) {
179
+ session.livenessPassed = livenessPassed;
180
+ CameraKit.canvasToBlob(frame).then(function (blob) {
181
+ session.selfieBlob = blob;
182
+ overlay.selfiePreview.src = URL.createObjectURL(blob);
183
+ showStep(overlay, "review");
184
+ });
185
+ }
186
+
187
+ function captureSelfie(session, overlay, livenessPassed) {
188
+ session.phase = "done";
189
+ var frame = CameraKit.captureFrame(overlay.video);
190
+ finishChallenge(session, overlay, frame, livenessPassed);
191
+ }
192
+
193
+ function submit(session, overlay, input, verifyUrl, statusEl) {
194
+ if (!session.idBlob || !session.selfieBlob) return;
195
+
196
+ overlay.sendBtn.disabled = true;
197
+ overlay.reviewStatus.textContent = "Vérification en cours…";
198
+
199
+ var formData = new FormData();
200
+ formData.append("id_document", session.idBlob, "id_document.jpg");
201
+ formData.append("selfie", session.selfieBlob, "selfie.jpg");
202
+ formData.append("liveness_passed", session.livenessPassed ? "true" : "false");
203
+
204
+ var csrfToken = getCookie("csrftoken");
205
+
206
+ fetch(verifyUrl, {
207
+ method: "POST",
208
+ headers: csrfToken ? { "X-CSRFToken": csrfToken } : {},
209
+ credentials: "same-origin",
210
+ body: formData,
211
+ })
212
+ .then(function (response) {
213
+ return response.json().then(function (data) {
214
+ return { ok: response.ok, data: data };
215
+ });
216
+ })
217
+ .then(function (result) {
218
+ overlay.sendBtn.disabled = false;
219
+ showResult(session, overlay, input, statusEl, result);
220
+ })
221
+ .catch(function (err) {
222
+ overlay.sendBtn.disabled = false;
223
+ overlay.reviewStatus.textContent = "Erreur réseau : " + err.message;
224
+ });
225
+ }
226
+
227
+ function showResult(session, overlay, input, statusEl, result) {
228
+ if (!result.ok) {
229
+ overlay.resultText.textContent = "Erreur : impossible de traiter la vérification.";
230
+ showStep(overlay, "result");
231
+ return;
232
+ }
233
+
234
+ var data = result.data;
235
+ if (data.status === "verified") {
236
+ overlay.resultText.textContent = "Identité vérifiée.";
237
+ input.value = data.id;
238
+ statusEl.textContent = "Vérifié";
239
+ input.dispatchEvent(new Event("change", { bubbles: true }));
240
+ } else {
241
+ overlay.resultText.textContent = "Vérification refusée — réessayez.";
242
+ statusEl.textContent = "Non vérifié";
243
+ }
244
+
245
+ showStep(overlay, "result");
246
+ }
247
+
248
+ function closeOverlay(session, overlay) {
249
+ session.running = false;
250
+ CameraKit.stopStream(session.stream);
251
+ overlay.root.remove();
252
+ }
253
+
254
+ function showStep(overlay, step) {
255
+ overlay.root.querySelectorAll("[data-step]").forEach(function (el) {
256
+ el.hidden = el.dataset.step !== step;
257
+ });
258
+ }
259
+
260
+ function buildOverlay() {
261
+ var root = document.createElement("div");
262
+ root.className = "camera-kit-overlay";
263
+ root.innerHTML = [
264
+ '<div class="camera-kit-modal">',
265
+ ' <div class="camera-kit-status"></div>',
266
+
267
+ ' <div data-step="id" class="camera-kit-step">',
268
+ ' <div class="camera-kit-video-wrapper camera-kit-kyc-id-wrapper">',
269
+ ' <div class="camera-kit-kyc-id-guide"></div>',
270
+ " </div>",
271
+ ' <div class="camera-kit-actions">',
272
+ ' <button type="button" class="btn btn-secondary camera-kit-cancel">Annuler</button>',
273
+ ' <button type="button" class="btn btn-primary camera-kit-capture-id">Capturer la pièce d’identité</button>',
274
+ " </div>",
275
+ " </div>",
276
+
277
+ ' <div data-step="selfie" class="camera-kit-step" hidden>',
278
+ ' <div class="camera-kit-video-wrapper camera-kit-kyc-selfie-wrapper">',
279
+ ' <div class="camera-kit-kyc-face-guide"></div>',
280
+ " </div>",
281
+ ' <p class="camera-kit-kyc-hint"></p>',
282
+ ' <div class="camera-kit-actions">',
283
+ ' <button type="button" class="btn btn-secondary camera-kit-cancel">Annuler</button>',
284
+ ' <button type="button" class="btn btn-link camera-kit-kyc-manual-selfie">Ça ne marche pas, capturer manuellement</button>',
285
+ " </div>",
286
+ " </div>",
287
+
288
+ ' <div data-step="review" class="camera-kit-step" hidden>',
289
+ ' <div class="camera-kit-kyc-review">',
290
+ ' <img class="camera-kit-kyc-id-preview" alt="Pièce d’identité capturée">',
291
+ ' <img class="camera-kit-kyc-selfie-preview" alt="Selfie capturé">',
292
+ " </div>",
293
+ ' <p class="camera-kit-kyc-review-status"></p>',
294
+ ' <div class="camera-kit-actions">',
295
+ ' <button type="button" class="btn btn-link camera-kit-kyc-restart">Recommencer tout</button>',
296
+ ' <button type="button" class="btn btn-secondary camera-kit-kyc-retake-selfie">Recommencer le selfie</button>',
297
+ ' <button type="button" class="btn btn-success camera-kit-kyc-send">Envoyer</button>',
298
+ " </div>",
299
+ " </div>",
300
+
301
+ ' <div data-step="result" class="camera-kit-step" hidden>',
302
+ ' <p class="camera-kit-kyc-result"></p>',
303
+ ' <div class="camera-kit-actions">',
304
+ ' <button type="button" class="btn btn-primary camera-kit-kyc-close">Fermer</button>',
305
+ " </div>",
306
+ " </div>",
307
+ "</div>",
308
+ ].join("\n");
309
+
310
+ // A single <video> element, moved between the id/selfie step wrappers
311
+ // by enterIdStep()/enterSelfieStep() — not two separate elements, so
312
+ // the live stream is always attached to whichever one is visible.
313
+ var video = document.createElement("video");
314
+ video.autoplay = true;
315
+ video.playsInline = true;
316
+ video.muted = true;
317
+
318
+ var idVideoWrapper = root.querySelector(".camera-kit-kyc-id-wrapper");
319
+ idVideoWrapper.appendChild(video);
320
+
321
+ return {
322
+ root: root,
323
+ status: root.querySelector(".camera-kit-status"),
324
+ video: video,
325
+ idVideoWrapper: idVideoWrapper,
326
+ selfieVideoWrapper: root.querySelector(".camera-kit-kyc-selfie-wrapper"),
327
+ hint: root.querySelector(".camera-kit-kyc-hint"),
328
+ captureIdBtn: root.querySelector(".camera-kit-capture-id"),
329
+ restartBtn: root.querySelector(".camera-kit-kyc-restart"),
330
+ manualSelfieBtn: root.querySelector(".camera-kit-kyc-manual-selfie"),
331
+ retakeSelfieBtn: root.querySelector(".camera-kit-kyc-retake-selfie"),
332
+ idPreview: root.querySelector(".camera-kit-kyc-id-preview"),
333
+ selfiePreview: root.querySelector(".camera-kit-kyc-selfie-preview"),
334
+ reviewStatus: root.querySelector(".camera-kit-kyc-review-status"),
335
+ sendBtn: root.querySelector(".camera-kit-kyc-send"),
336
+ resultText: root.querySelector(".camera-kit-kyc-result"),
337
+ closeBtn: root.querySelector(".camera-kit-kyc-close"),
338
+ cancelBtns: Array.prototype.slice.call(root.querySelectorAll(".camera-kit-cancel")),
339
+ };
340
+ }
341
+
342
+ if (document.readyState === "loading") {
343
+ document.addEventListener("DOMContentLoaded", init);
344
+ } else {
345
+ init();
346
+ }
347
+ })(window, document);
@@ -0,0 +1,7 @@
1
+ <div class="camera-kit-kyc" data-verify-url="{{ widget.verify_url|default:'' }}">
2
+ <input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
3
+ <button type="button" class="btn btn-outline-primary camera-kit-kyc-trigger">
4
+ Vérifier mon identité
5
+ </button>
6
+ <span class="camera-kit-kyc-status"></span>
7
+ </div>
@@ -0,0 +1,20 @@
1
+ from rest_framework.throttling import UserRateThrottle
2
+
3
+ from . import conf
4
+
5
+
6
+ class KYCVerifyThrottle(UserRateThrottle):
7
+ """Rate-limits KYCVerifyView per authenticated user.
8
+
9
+ Each request runs a full face-embedding inference pass — without a
10
+ limit, the endpoint is a cheap resource-exhaustion and brute-force
11
+ probing vector. Reads from DJANGO_CAMERA_KIT_KYC.THROTTLE_RATE
12
+ (see conf.py) instead of DRF's global DEFAULT_THROTTLE_RATES, so this
13
+ reusable app doesn't require the consuming project to configure DRF
14
+ throttle scopes globally.
15
+ """
16
+
17
+ scope = "camera_kit_kyc_verify"
18
+
19
+ def get_rate(self):
20
+ return conf.get_setting("THROTTLE_RATE")
@@ -0,0 +1,9 @@
1
+ from django.urls import path
2
+
3
+ from .views import KYCVerifyView
4
+
5
+ app_name = "camera_kit_kyc"
6
+
7
+ urlpatterns = [
8
+ path("verify/", KYCVerifyView.as_view(), name="verify"),
9
+ ]
@@ -0,0 +1,65 @@
1
+ import cv2
2
+ import numpy as np
3
+ from rest_framework.permissions import IsAuthenticated
4
+ from rest_framework.response import Response
5
+ from rest_framework.views import APIView
6
+
7
+ from . import conf
8
+ from .embeddings import compare_embeddings, extract_face_embedding
9
+ from .models import KYCVerification
10
+ from .serializers import KYCSubmissionSerializer, KYCVerificationSerializer
11
+ from .throttling import KYCVerifyThrottle
12
+
13
+
14
+ def _read_image_bgr(django_file):
15
+ data = np.frombuffer(django_file.read(), dtype=np.uint8)
16
+ django_file.seek(0)
17
+ return cv2.imdecode(data, cv2.IMREAD_COLOR)
18
+
19
+
20
+ class KYCVerifyView(APIView):
21
+ """Accepts a selfie and an ID document photo, extracts a face embedding
22
+ from each server-side, and compares them. Matching only ever happens
23
+ here — no client-supplied embedding or score is ever trusted.
24
+
25
+ Requires authentication: a verification is meaningless without knowing
26
+ whose identity it claims to confirm, and the endpoint runs a full face-
27
+ embedding inference pass per request, so it also needs to be rate
28
+ limited (KYCVerifyThrottle) rather than open to anonymous callers.
29
+ """
30
+
31
+ permission_classes = [IsAuthenticated]
32
+ throttle_classes = [KYCVerifyThrottle]
33
+
34
+ def post(self, request):
35
+ submission = KYCSubmissionSerializer(data=request.data)
36
+ submission.is_valid(raise_exception=True)
37
+
38
+ verification = KYCVerification.objects.create(
39
+ user=request.user,
40
+ selfie=submission.validated_data["selfie"],
41
+ id_document=submission.validated_data["id_document"],
42
+ liveness_passed=submission.validated_data["liveness_passed"],
43
+ )
44
+
45
+ selfie_embedding = extract_face_embedding(_read_image_bgr(verification.selfie))
46
+ id_embedding = extract_face_embedding(_read_image_bgr(verification.id_document))
47
+
48
+ if selfie_embedding is None or id_embedding is None:
49
+ verification.status = KYCVerification.Status.REJECTED
50
+ verification.save(update_fields=["status"])
51
+ return Response(KYCVerificationSerializer(verification).data)
52
+
53
+ score = compare_embeddings(selfie_embedding, id_embedding)
54
+ verification.selfie_embedding = selfie_embedding
55
+ verification.id_face_embedding = id_embedding
56
+ verification.match_score = score
57
+ verification.status = (
58
+ KYCVerification.Status.VERIFIED
59
+ if score >= conf.get_setting("MATCH_THRESHOLD")
60
+ and verification.liveness_passed
61
+ else KYCVerification.Status.REJECTED
62
+ )
63
+ verification.save()
64
+
65
+ return Response(KYCVerificationSerializer(verification).data)
@@ -0,0 +1,39 @@
1
+ from django import forms
2
+ from django.urls import NoReverseMatch, reverse
3
+
4
+
5
+ class KYCVerificationWidget(forms.HiddenInput):
6
+ """Hidden field populated with a KYCVerification id once the user
7
+ completes the live capture + server-side verification flow.
8
+
9
+ Renders a "Vérifier mon identité" button next to the hidden input —
10
+ unlike DocumentScannerWidget, there is deliberately no file-upload
11
+ fallback: allowing a raw upload here would defeat the whole point of
12
+ the liveness challenge (anyone could submit a stolen photo).
13
+ """
14
+
15
+ template_name = "django_camera_kit/kyc/widgets/kyc_verification.html"
16
+
17
+ class Media:
18
+ css = {"all": ("django_camera_kit/kyc/css/kyc_capture.css",)}
19
+ js = (
20
+ "django_camera_kit/vendor/opencv.js",
21
+ "django_camera_kit/js/camera_kit.js",
22
+ "django_camera_kit/kyc/js/face_kit.js",
23
+ "django_camera_kit/kyc/js/kyc_capture.js",
24
+ )
25
+
26
+ def __init__(self, attrs=None, verify_url=None):
27
+ super().__init__(attrs)
28
+ self.verify_url = verify_url
29
+
30
+ def get_context(self, name, value, attrs):
31
+ context = super().get_context(name, value, attrs)
32
+ context["widget"]["verify_url"] = self.verify_url or self._default_verify_url()
33
+ return context
34
+
35
+ def _default_verify_url(self):
36
+ try:
37
+ return reverse("camera_kit_kyc:verify")
38
+ except NoReverseMatch:
39
+ return None
@@ -0,0 +1,97 @@
1
+ .camera-kit-scanner {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.5rem;
5
+ flex-wrap: wrap;
6
+ }
7
+
8
+ /* Once doc_scan.js has bound the widget, the button is the only way in —
9
+ the native file input stays in the DOM (still submits the scanned file)
10
+ but is hidden. If JS never runs, this class is never added and the
11
+ native picker remains visible as the fallback. */
12
+ .camera-kit-scanner.camera-kit-js-ready input[type="file"] {
13
+ display: none;
14
+ }
15
+
16
+ .camera-kit-overlay {
17
+ position: fixed;
18
+ inset: 0;
19
+ background: rgba(0, 0, 0, 0.85);
20
+ z-index: 1050;
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ padding: 1rem;
25
+ }
26
+
27
+ .camera-kit-modal {
28
+ background: #fff;
29
+ border-radius: 0.5rem;
30
+ max-width: 640px;
31
+ width: 100%;
32
+ max-height: 90vh;
33
+ overflow: hidden;
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+
38
+ .camera-kit-status {
39
+ padding: 0.5rem 1rem;
40
+ color: #b02a37;
41
+ font-size: 0.875rem;
42
+ min-height: 1.5rem;
43
+ }
44
+
45
+ .camera-kit-video-wrapper,
46
+ .camera-kit-adjust-wrapper {
47
+ position: relative;
48
+ background: #000;
49
+ line-height: 0;
50
+ }
51
+
52
+ .camera-kit-video-wrapper video,
53
+ .camera-kit-video-wrapper canvas,
54
+ .camera-kit-adjust-wrapper canvas {
55
+ width: 100%;
56
+ height: auto;
57
+ display: block;
58
+ }
59
+
60
+ .camera-kit-video-wrapper canvas {
61
+ position: absolute;
62
+ top: 0;
63
+ left: 0;
64
+ pointer-events: none;
65
+ }
66
+
67
+ .camera-kit-handle {
68
+ position: absolute;
69
+ width: 24px;
70
+ height: 24px;
71
+ border-radius: 50%;
72
+ background: #00723d;
73
+ border: 2px solid #fff;
74
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
75
+ touch-action: none;
76
+ cursor: grab;
77
+ }
78
+
79
+ .camera-kit-actions {
80
+ display: flex;
81
+ justify-content: flex-end;
82
+ gap: 0.5rem;
83
+ padding: 0.75rem 1rem;
84
+ }
85
+
86
+ .camera-kit-page-strip {
87
+ display: flex;
88
+ gap: 0.5rem;
89
+ padding: 1rem;
90
+ overflow-x: auto;
91
+ }
92
+
93
+ .camera-kit-thumb {
94
+ height: 96px;
95
+ border-radius: 0.25rem;
96
+ border: 1px solid #dee2e6;
97
+ }