cc12458 7 месяцев назад
Родитель
Сommit
12374f01b9

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "p5": "^1.11.0",
     "pinia": "^2.2.4",
     "pinia-plugin-persistedstate": "^4.1.1",
+    "svg-pathdata": "^7.1.0",
     "vant": "4",
     "vconsole": "^3.15.1",
     "vue": "^3.5.11",

+ 15 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ dependencies:
   pinia-plugin-persistedstate:
     specifier: ^4.1.1
     version: 4.1.1(pinia@2.2.4)
+  svg-pathdata:
+    specifier: ^7.1.0
+    version: 7.1.0
   vant:
     specifier: '4'
     version: 4.9.8(vue@3.5.11)
@@ -3736,6 +3739,13 @@ packages:
     engines: {node: '>= 0.4'}
     dev: true
 
+  /svg-pathdata@7.1.0:
+    resolution: {integrity: sha512-wrvKHXZSYZyODOj5E1l1bMTIo8sR7YCH0E4SA8IgLgMsZq4RypslpYvNSsrdg4ThD6du2KWPyVeKinkqUelGhg==}
+    engines: {node: '>=20.11.1'}
+    dependencies:
+      yerror: 8.0.0
+    dev: false
+
   /svg-tags@1.0.0:
     resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
     dev: true
@@ -4349,6 +4359,11 @@ packages:
     hasBin: true
     dev: true
 
+  /yerror@8.0.0:
+    resolution: {integrity: sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g==}
+    engines: {node: '>=18.16.0'}
+    dev: false
+
   /yocto-queue@0.1.0:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}

+ 35 - 24
src/modules/camera/ShadeFace.vue

@@ -1,32 +1,43 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
 
+
+const Shade: SVGPathData[] = [
+  'M0 183A136 183 0 11272 183 136 183 0 110 183Z',
+].map(d => new SVGPathData(d));
+
+const {
+  translateX = 0, translateY = 0,
+  scaleX = 1, scaleY = 1,
+} = defineProps<{
+  translateX?: number; translateY?: number;
+  scaleX?: number; scaleY?: number
+}>();
+
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scaleX, scaleY))
+    .encode(),
+  );
+});
+</script>
 <template>
-  <svg width="225px" height="305px" viewBox="0 0 225 305" xmlns="http://www.w3.org/2000/svg">
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <ellipse
-        cx="135"
-        cy="183"
-        rx="135"
-        ry="183"
-        fill="none"
-        stroke="#FEFEFE"
-        stroke-width="2"
-        stroke-linecap="round"
-        stroke-linejoin="round"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
-    <ellipse
-      cx="112.5"
-      cy="152.5"
-      rx="112.5"
-      ry="152.5"
-      fill="none"
-      stroke="#FEFEFE"
+    <path
+      id="face"
+      stroke="#fefefe"
       stroke-width="2"
-      stroke-linecap="round"
-      stroke-linejoin="round"
+      fill="none"
+      d="M0 183A136 183 0 11272 183 136 183 0 110 183Z"
     />
   </svg>
 </template>
-
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+svg { opacity: 0.85; }
+</style>

+ 28 - 12
src/modules/camera/ShadeTongueDown.vue

@@ -1,15 +1,33 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
 
+
+const Shade: SVGPathData[] = [
+  'M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z',
+].map(d => new SVGPathData(d));
+
+const {
+  translateX = 0, translateY = 0,
+  scaleX = 1, scaleY = 1,
+} = defineProps<{
+  translateX?: number; translateY?: number;
+  scaleX?: number; scaleY?: number
+}>();
+
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scaleX, scaleY))
+    .encode(),
+  );
+});
+</script>
 <template>
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <path
-        id="outline"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
     <path
       id="outline"
@@ -27,9 +45,7 @@
     />
   </svg>
 </template>
-
 <style scoped lang="scss">
-svg {
-  opacity: 0.85;
-}
+svg { opacity: 0.85; }
 </style>
+

+ 28 - 19
src/modules/camera/ShadeTongueUp.vue

@@ -1,23 +1,34 @@
 <script setup lang="ts">
-</script>
+import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
+
+
+const Shade: SVGPathData[] = [
+  'M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192Z',
+  'M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z',
+].map(d => new SVGPathData(d));
+
+const {
+  translateX = 0, translateY = 0,
+  scaleX = 1, scaleY = 1,
+} = defineProps<{
+  translateX?: number; translateY?: number;
+  scaleX?: number; scaleY?: number
+}>();
 
+const paths = ref<string[]>([]);
+
+watchEffect(() => {
+  paths.value = Shade.map(data => data
+    .transform(SVGPathDataTransformer.TRANSLATE(translateX, translateY))
+    .transform(SVGPathDataTransformer.SCALE(scaleX, scaleY))
+    .encode(),
+  );
+});
+</script>
 <template>
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 272 366">
     <clipPath id="shade">
-      <path
-        id="outline-up"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M21 193C21 193-46 60 78 9 84 7 91 4 98 4 105 3 115 3 122 4 136 7 140 7 160 3 182 0 324 31 249 192Z"
-      />
-      <path
-        id="outline-down"
-        stroke="#fefefe"
-        stroke-width="2"
-        fill="none"
-        d="M139 127C139 127 49 81 27 152 6 223 51 357 130 364 205 370 244 331 249 181 249 181 259 75 139 127Z"
-      />
+      <path v-for="(d,i) in paths" :id="'outline-'+i" stroke="#fefefe" stroke-width="2" fill="none" :d="d" />
     </clipPath>
     <path
       id="outline"
@@ -42,9 +53,7 @@
     />
   </svg>
 </template>
-
 <style scoped lang="scss">
-svg {
-  opacity: 0.85;
-}
+svg { opacity: 0.85; }
 </style>
+

+ 86 - 37
src/modules/camera/camera.vue

@@ -1,26 +1,43 @@
 <script setup lang="ts">
-import { withResolvers }                                  from '@/tools';
-import { getMediaStream }                                 from '@/tools/camera.tool';
-import { tryOnMounted, tryOnUnmounted, useEventListener } from '@vueuse/core';
+import { withResolvers }                                                  from '@/tools';
+import { getMediaStream }                                                 from '@/tools/camera.tool';
+import { tryOnMounted, tryOnUnmounted, useElementSize, useEventListener } from '@vueuse/core';
 
 
-const { constraints = {} } = defineProps<{ constraints?: MediaTrackConstraints }>();
+let _stream: MediaStream;
+
+
+const { constraints = {} } = defineProps<{ constraints?: MediaTrackConstraints, multiple?: number; }>();
 const width = defineModel('width', { default: 0 });
 const height = defineModel('height', { default: 0 });
+const multiple = defineModel('multiple', { default: 1 });
+
+const offsetX = ref(0);
+const offsetY = ref(0);
+
+watch([ width, height, multiple ], ([ w, h, m ]) => {
+  applyConstraints({ width: w * m, height: h * m });
+});
+
 
 const styleValue = computed(() => {
   return width.value && height.value
-         ? {
-      width: `${ width.value }px`,
-      height: `${ height.value }px`,
-    }
+         ? { width: `${ width.value }px`, height: `${ height.value }px` }
          : void 0;
 });
 
+const containerStyleValue = reactive({
+  width: `${ width.value }px`,
+  height: `${ height.value }px`,
+});
+
 const container = useTemplateRef<HTMLElement>('camera-container');
 const videoRef = ref<HTMLVideoElement | null>(null);
 const canvasRef = ref<HTMLCanvasElement | null>(null);
 
+/* 根容器 宽高 */
+const root = useElementSize(() => container.value?.parentElement);
+
 const getVideoTrack = () => {
   return videoRef.value?.srcObject instanceof MediaStream
          ? videoRef.value.srcObject.getVideoTracks()[ 0 ]
@@ -36,52 +53,81 @@ useEventListener(videoRef, 'canplay', (event) => {
   const settings = track.getSettings();
   const { width: w = 1, height: h = 1, aspectRatio = w / h } = settings;
   const reverse = aspectRatio > 1;
-  width.value = reverse ? h : w;
-  height.value = reverse ? w : h;
-  video.setAttribute('width', `${ width.value }`);
-  video.setAttribute('height', `${ height.value }`);
-  canvas!.width = width.value;
-  canvas!.height = height.value;
+  const _width = reverse ? h : w;
+  const _height = reverse ? w : h;
+  video.setAttribute('width', `${ _width }`);
+  video.setAttribute('height', `${ _height }`);
+
+  containerStyleValue.width = `${ _width }px`;
+  containerStyleValue.height = `${ _height }px`;
+
+  offsetX.value = (
+                    _width - width.value
+                  ) / 2;
+  offsetY.value = (
+                    _height - height.value
+                  ) / 2;
 
   video.play().then(
     () => {},
     // (_) => (error.value = _)
   );
   console.group('[log] camera:', '流媒体可播放');
-  console.log(`width=${ width.value }, height=${ height.value }`);
+  console.log(`width=${ _width }, height=${ _height }`);
   console.log(`浏览器支持: 可缩放 ${ !!(
     <any> navigator.mediaDevices.getSupportedConstraints()
   ).zoom }`);
   console.log(`摄像头支持: 可缩放 ${ !!(
     <any> track.getCapabilities()
   )[ 'zoom' ] }`);
+  console.log(`当前设置缩放: ${ multiple.value }`);
+  console.log(`当前实际缩放: (${ _width / width.value }, ${ _height / height.value })`);
+  console.log(`图象偏移距离: (${ offsetX.value }, ${ offsetY.value })`);
   console.groupEnd();
 });
 
 tryOnMounted(() => {
-  const style = getComputedStyle(container.value!);
-  width.value ??= Number.parseInt(style.width);
-  height.value ??= Number.parseInt(style.height);
-  init(container.value!, { width: width.value, height: height.value, ...constraints });
-});
+  const _container = container.value!;
+  canvasRef.value = container.value!.querySelector('canvas');
+  videoRef.value = container.value!.querySelector('video');
 
-tryOnUnmounted(() => {
-  getVideoTrack().stop();
+  const style = getComputedStyle(_container);
+  const _width = width.value || Number.parseInt(style.width);
+  const _height = height.value || Number.parseInt(style.height);
+  const _multiple = multiple.value ?? 1;
 
-  const video = videoRef.value;
-  if ( video ) {
-    video.pause();
-    video.srcObject = null;
-  }
+  canvasRef.value!.width = _width;
+  canvasRef.value!.height = _height;
+
+  applyConstraints({ ...constraints, width: _width * _multiple, height: _height * _multiple });
+});
 
+tryOnUnmounted(() => {
+  destroy();
   videoRef.value = null;
   canvasRef.value = null;
 });
 
-async function init(container: HTMLElement, constraints: MediaTrackConstraints) {
-  canvasRef.value = container.querySelector('canvas');
-  videoRef.value = container.querySelector('video');
-  videoRef.value!.srcObject = await getMediaStream(<any> constraints);
+async function applyConstraints(constraints: Partial<MediaTrackConstraints>) {
+  const { width, height, ..._constraints } = constraints;
+  // @ts-ignore
+  if ( !!width && !!height ) {
+    destroy();
+    _stream = await getMediaStream(<any> { ..._constraints, width, height, facingMode: 'user' });
+    videoRef.value!.srcObject = _stream;
+  } else {
+    await _stream.getVideoTracks()[ 0 ]?.applyConstraints(constraints);
+  }
+}
+
+function destroy() {
+  const tracks = _stream?.getTracks() ?? [];
+  for ( const track of tracks ) track.stop();
+  _stream = void 0 as any;
+  if ( videoRef.value ) {
+    videoRef.value.pause();
+    videoRef.value.srcObject = null as any;
+  }
 }
 
 
@@ -92,7 +138,8 @@ const handle = async (promise: Promise<boolean>) => {
   if ( video.paused ) await video.play();
 
   const context = canvas.getContext('2d')!;
-  context.drawImage(video, 0, 0, canvas.width, canvas.height);
+  context.clearRect(0, 0, canvas.width, canvas.height);
+  context.drawImage(video, offsetX.value, offsetY.value, video.width, video.height, 0, 0, canvas.width, canvas.height);
   video.pause();
 
   promise.then(value => video.paused && value ? video.play() : void 0);
@@ -119,20 +166,22 @@ defineExpose({
 });
 </script>
 <template>
-  <div class="camera-container size-full" ref="camera-container" :style="styleValue">
+  <div class="camera-container size-full" ref="camera-container" :style="containerStyleValue">
     <canvas style="display: none;"></canvas>
     <video></video>
-    <slot></slot>
+    <slot :style="styleValue" :offsetX="offsetX" :offsetY="offsetY"></slot>
   </div>
 </template>
 <style scoped lang="scss">
 .camera-container {
-  position: relative;
+  position: absolute;
+  z-index: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
 
   > * {
     position: absolute;
-    width: 100%;
-    height: 100%;
   }
 
   video {

+ 19 - 10
src/modules/camera/page.vue

@@ -24,6 +24,7 @@ const area = reactive({
   // height: 400,
   width: 270,
   height: 366,
+  multiple: 2,
 });
 
 onBeforeMount(() => next());
@@ -165,18 +166,21 @@ const transparency = ref(1);
       <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
         <div class="text-3xl text-center" :class="{ required: config?.required }">{{ config?.label }}</div>
         <div class="mt-8 text-lg text-center tracking-wider leading-10">{{ config?.description }}</div>
+        <div>w: {{ area.width }}, h: {{ area.height }}, m: {{ area.multiple }}</div>
       </header>
       <main class="relative flex justify-center items-center" style="flex: 1 1 40%">
-        <Camera ref="camera" v-bind="area">
-          <component :is="config?.shade"></component>
-          <img
-            v-if="showExample"
-            class="example"
-            :style="{ opacity: transparency }"
-            :src="config?.example"
-            alt="示例"
-            @click="showExample = false"
-          />
+        <Camera ref="camera" v-model:width="area.width" v-model:height="area.height" v-model:multiple="area.multiple">
+          <template #default="{style, offsetX, offsetY}">
+            <component :is="config?.shade" :style="style" :translateX="offsetX" :translateY="offsetY"></component>
+            <img
+              v-if="showExample"
+              class="example"
+              :style="{ opacity: transparency, ...style }"
+              :src="config?.example"
+              alt="示例"
+              @click="showExample = false"
+            />
+          </template>
         </Camera>
         <div
           v-if="config?.example"
@@ -211,4 +215,9 @@ const transparency = ref(1);
     color: #f53030;
   }
 }
+
+header, footer {
+  position: relative;
+  z-index: 11;
+}
 </style>