Photo.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. <script setup>
  2. import { ref, onMounted, defineEmits } from "vue";
  3. import { useMainStore } from "@/stores/store";
  4. // Axios
  5. import axios from "axios";
  6. // i18n
  7. import { useI18n } from "vue-i18n";
  8. // Qrcode.vue
  9. // import QrcodeVue from "qrcode.vue";
  10. const { t } = useI18n();
  11. const store = useMainStore();
  12. const vlogVideo = ref(null);
  13. const canvas = ref(null);
  14. const photo = ref(null);
  15. let showVideo = ref(true);
  16. const emit = defineEmits(["closeDialog"]);
  17. // 關閉父組件的 funFilterDialog
  18. function closeDialog() {
  19. emit("closeDialog");
  20. }
  21. onMounted(() => {
  22. // 請求使用者的攝影機
  23. navigator.mediaDevices
  24. .getUserMedia({ video: true })
  25. .then((stream) => {
  26. vlogVideo.value.srcObject = stream;
  27. })
  28. .catch((error) => {
  29. console.error("無法開啟攝影機", error);
  30. });
  31. });
  32. let loading = ref(false);
  33. let havePhoto = ref(false); // 拍照完成
  34. let isCompleted = ref(false); // 照片確認
  35. let countdown = ref(0);
  36. // 倒數五秒拍攝
  37. function startCountdown() {
  38. countdown.value = 5;
  39. loading.value = true;
  40. const interval = setInterval(() => {
  41. countdown.value -= 1;
  42. if (countdown.value === 0) {
  43. clearInterval(interval);
  44. takePhoto();
  45. }
  46. }, 1000);
  47. }
  48. startCountdown();
  49. let photoFile = ref(null);
  50. // 拍攝照片
  51. const takePhoto = () => {
  52. const context = canvas.value.getContext("2d");
  53. canvas.value.width = vlogVideo.value.videoWidth;
  54. canvas.value.height = vlogVideo.value.videoHeight;
  55. context.drawImage(
  56. vlogVideo.value,
  57. 0,
  58. 0,
  59. vlogVideo.value.videoWidth,
  60. vlogVideo.value.videoHeight
  61. );
  62. // 將畫布轉換成 base64 格式的 JPG 照片,不調整畫質
  63. photo.value = canvas.value.toDataURL("image/jpeg"); // 不設置壓縮品質
  64. if (photo.value) {
  65. showVideo.value = false;
  66. // 將 base64 轉換成 Blob
  67. const photoBlob = dataURLtoBlob(photo.value);
  68. // 將 Blob 轉換成 File
  69. photoFile.value = blobToFile(photoBlob, "photo.jpg"); // 設定檔案名稱為 JPG 格式
  70. havePhoto.value = true;
  71. loading.value = false;
  72. }
  73. };
  74. // 重新拍攝
  75. function reshoot() {
  76. // 清空照片和檔案資料
  77. photo.value = null;
  78. photoFile.value = null;
  79. // 顯示影片重新準備拍攝
  80. showVideo.value = true;
  81. havePhoto.value = false;
  82. loading.value = false;
  83. startCountdown();
  84. console.log("重新拍攝,重設狀態");
  85. }
  86. // base64 轉 Blob
  87. function dataURLtoBlob(dataURL) {
  88. const byteString = atob(dataURL.split(",")[1]);
  89. const mimeString = dataURL.split(",")[0].split(":")[1].split(";")[0];
  90. const ab = new ArrayBuffer(byteString.length);
  91. const ia = new Uint8Array(ab);
  92. for (let i = 0; i < byteString.length; i++) {
  93. ia[i] = byteString.charCodeAt(i);
  94. }
  95. return new Blob([ab], { type: mimeString });
  96. }
  97. // Blob 轉 File
  98. function blobToFile(blob, fileName) {
  99. return new File([blob], fileName, { type: blob.type });
  100. }
  101. let swapVideo = ref(null);
  102. let swapVideoSrc = ref(null);
  103. let form = ref(false);
  104. let email = ref(null);
  105. let formLoading = ref(false);
  106. let showAlert = ref(false);
  107. // let imgUrl = ref(null); // 明信片圖
  108. // const required = (v) => !!v || "請輸入您的 email";
  109. const required = (v) => {
  110. const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  111. return emailPattern.test(v) || "請輸入有效的 email 格式";
  112. };
  113. async function onSubmit() {
  114. formLoading.value = true;
  115. let url = `https://cmm.ai/postcard/fs/swap-face-video/${store.assignGender}?email_to=${email.value}`;
  116. const formData = new FormData();
  117. formData.append("file", photoFile.value);
  118. console.log("url", url);
  119. try {
  120. const response = await axios.post(url, formData);
  121. console.log("換臉影片 response", response);
  122. if (response.data === "OK") {
  123. showAlert.value = true;
  124. setTimeout(() => {
  125. showAlert.value = false;
  126. closeDialog();
  127. }, 3000);
  128. }
  129. formLoading.value = false;
  130. } catch (error) {
  131. console.log("error", error);
  132. }
  133. }
  134. let isUploading = ref(false);
  135. let imageUrl = ref(null); // 圖片網址
  136. // 上傳
  137. async function upload() {
  138. isUploading.value = true;
  139. // isCompleted.value = true;
  140. if (!photoFile.value) {
  141. return;
  142. }
  143. store.imgPath = "";
  144. loading.value = true;
  145. let url = `https://cmm.ai/postcard/fs/swap-face/${store.assignGender}/${store.assignRace}/${store.assignBgImg}`;
  146. // 人物圖
  147. const formData = new FormData();
  148. formData.append("file", photoFile.value);
  149. try {
  150. let response = await axios.post(url, formData);
  151. console.log("runImg", response);
  152. if (response.status === 200) {
  153. isUploading.value = false;
  154. isCompleted.value = true;
  155. // store.imgPath = response.data[0].path;
  156. const filePath = response.data;
  157. const fileName = filePath.match(/results\/(.+)$/)[1];
  158. console.log("fileName", fileName);
  159. imageUrl.value = `https://cmm.ai/postcard/fs/result-image/${fileName}`;
  160. // 圖檔轉網址
  161. // let blob = new Blob([response.data], { type: "image/png" }); // 創建 blob
  162. // let imageUrl = URL.createObjectURL(blob); // 創建圖片 URL
  163. // store.imgFile = blob; // 圖片檔案
  164. // store.imgPath = imageUrl; // 圖片網址
  165. // loading.value = false;
  166. // alert("完成");
  167. // router.push("/step7");
  168. }
  169. } catch (error) {
  170. console.log("error", error);
  171. }
  172. }
  173. let vlogEmail = ref("");
  174. let vlogLoading = ref(false);
  175. let showVlogAlert = ref(false);
  176. let emailError = ref(false);
  177. // 取得 vlog
  178. async function getVlog() {
  179. console.log("getVlog");
  180. console.log("vlogEmail", vlogEmail.value);
  181. vlogLoading.value = true;
  182. // isCompleted.value = true;
  183. // if (!photoFile.value) {
  184. // return;
  185. // }
  186. // store.imgPath = "";
  187. // loading.value = true;
  188. let url = `https://cmm.ai/postcard/fs/swap-face-slide/${store.assignGender}/${store.assignRace}/${store.assignBgImg}?email_to=${vlogEmail.value}`;
  189. // 人物圖
  190. const formData = new FormData();
  191. formData.append("file", photoFile.value);
  192. try {
  193. let response = await axios.post(url, formData);
  194. vlogLoading.value = false;
  195. console.log("取得 vlog", response);
  196. if (response.data === "OK") {
  197. alert(`${t("vlog.submit_success")}!${t("vlog.processing_message")}`);
  198. // showVlogAlert.value = true;
  199. // setTimeout(() => {
  200. // showVlogAlert.value = false;
  201. // }, 5000);
  202. }
  203. } catch (error) {
  204. console.log("error", error);
  205. }
  206. }
  207. // 驗證 email 格式
  208. function validateAndSubmit() {
  209. const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  210. if (!emailPattern.test(vlogEmail.value)) {
  211. emailError.value = true;
  212. return;
  213. }
  214. emailError.value = false;
  215. getVlog();
  216. }
  217. </script>
  218. <template>
  219. <div
  220. v-if="store.funFilterDialog"
  221. class="d-flex flex-column align-center justify-center"
  222. >
  223. <video v-show="showVideo" ref="vlogVideo" autoplay playsinline></video>
  224. <div
  225. v-if="photo && !isCompleted && !isUploading"
  226. class="w-100 d-flex flex-column align-center"
  227. >
  228. <img :src="photo" alt="captured image" class="captured-img" />
  229. <p class="prompt-text">
  230. {{ t("remove_mask_result") }}
  231. </p>
  232. <div class="d-flex mt-10">
  233. <v-btn @click="reshoot()" class="me-5" variant="tonal">
  234. {{ t("retake") }}
  235. </v-btn>
  236. <v-btn @click="upload()" color="primary" variant="elevated">
  237. {{ t("confirm") }}
  238. </v-btn>
  239. </div>
  240. </div>
  241. <video v-show="swapVideoSrc" ref="swapVideo" preload playsinline>
  242. <source :src="swapVideoSrc" type="video/mp4" />
  243. </video>
  244. <!-- 倒數計時動畫 -->
  245. <div v-if="countdown > 0" class="countdown">{{ countdown }}</div>
  246. <div
  247. class="d-flex flex-column align-center justify-center"
  248. v-if="isUploading"
  249. >
  250. <v-progress-circular
  251. :size="100"
  252. color="primary"
  253. indeterminate
  254. class="my-10"
  255. ></v-progress-circular>
  256. <p class="mb-10 text-center">{{ t("postcard_being_created") }}</p>
  257. </div>
  258. <div v-if="isCompleted && imageUrl" class="my-10 position-relative">
  259. <div class="d-flex justify-center">
  260. <img :src="imageUrl" alt="" class="w-100" style="object-fit: contain" />
  261. <!-- <span class="ms-15 d-flex flex-column align-center justify-center">
  262. <p class="mb-10 text-h5 font-weight-bold">
  263. {{ t("qrcode_save_image") }}
  264. </p>
  265. <qrcode-vue :value="imageUrl" class="mb-2" size="300" level="H" />
  266. </span> -->
  267. </div>
  268. <div class="vlog-item">
  269. <h4>
  270. <span>{{ t("vlog.input_email") }}</span>
  271. </h4>
  272. <p>
  273. {{ t("vlog.description") }}
  274. </p>
  275. <v-text-field
  276. v-model="vlogEmail"
  277. :label="t('vlog.enter_email')"
  278. variant="solo"
  279. :error-messages="emailError ? [`${t('vlog.invalid_email')}`] : []"
  280. ></v-text-field>
  281. <v-btn
  282. @click="validateAndSubmit()"
  283. :loading="vlogLoading"
  284. color="primary"
  285. type="submit"
  286. variant="elevated"
  287. block
  288. >
  289. {{ t("submit") }}
  290. </v-btn>
  291. </div>
  292. <v-alert
  293. v-show="showVlogAlert"
  294. class="vlog-alert"
  295. :text="t('vlog.processing_message')"
  296. :title="t('vlog.submit_success')"
  297. type="success"
  298. ></v-alert>
  299. </div>
  300. <!-- <v-progress-circular
  301. v-else-if="loading"
  302. :size="50"
  303. color="primary"
  304. indeterminate
  305. ></v-progress-circular> -->
  306. <!-- <div v-if="isCompleted" class="form-content">
  307. <div
  308. v-if="loading"
  309. class="d-flex flex-column align-center justify-center py-15"
  310. >
  311. <v-progress-circular
  312. :size="70"
  313. :width="7"
  314. color="primary"
  315. indeterminate
  316. ></v-progress-circular>
  317. </div>
  318. <div v-else></div>
  319. <p class="mb-10 text-center">
  320. 拍攝完成!<br />
  321. 請提供您的電子郵件以取得影片
  322. <br />影片約 1-2 分鐘後將傳送至您的信箱<br />
  323. </p>
  324. <v-form v-model="form" @submit.prevent="onSubmit">
  325. <v-text-field
  326. v-model="email"
  327. :rules="[required]"
  328. variant="solo"
  329. class="mb-2"
  330. label="email"
  331. clearable
  332. ></v-text-field>
  333. <br />
  334. <v-btn
  335. :disabled="!form"
  336. :loading="formLoading"
  337. color="primary"
  338. size="large"
  339. type="submit"
  340. variant="elevated"
  341. block
  342. >
  343. 送出
  344. </v-btn>
  345. </v-form>
  346. </div> -->
  347. <!-- <div v-if="showAlert" class="alert-item">
  348. <v-alert
  349. density="compact"
  350. text="您的 vlog 影片已為您寄送,如尚未收到請耐心等候"
  351. title="送出成功!"
  352. type="success"
  353. ></v-alert>
  354. </div> -->
  355. <!-- <div v-if="havePhoto" class="d-flex flex-column align-center">
  356. <p class="text-center">拍照完成!<br />請掃描以下 QR Code 取得影片</p>
  357. <img src="../assets/img/video-results.png" alt="" />
  358. </div> -->
  359. <!-- <button>
  360. <span v-if="countdown > 0">拍照倒數 {{ countdown }} 秒</span>
  361. <span v-else class="text-center"
  362. >製作影片中,請稍候…</span
  363. >
  364. </button> -->
  365. <canvas ref="canvas" style="display: none"></canvas>
  366. </div>
  367. </template>
  368. <style lang="scss" scoped>
  369. .captured-img {
  370. max-width: 100%;
  371. margin: 1rem auto;
  372. }
  373. video {
  374. width: 100%;
  375. max-width: 750px;
  376. height: auto;
  377. margin: 1rem auto;
  378. }
  379. button {
  380. margin: auto;
  381. padding: 1.2rem 2rem;
  382. display: flex;
  383. justify-content: center;
  384. border-radius: 5px;
  385. letter-spacing: 3px;
  386. font-size: 1rem;
  387. }
  388. .countdown {
  389. position: absolute;
  390. top: 50%;
  391. left: 50%;
  392. transform: translate(-50%, -50%);
  393. font-size: 4em;
  394. font-weight: bold;
  395. color: #fff;
  396. }
  397. .result {
  398. width: 100%;
  399. display: flex;
  400. align-items: center;
  401. justify-content: center;
  402. img {
  403. width: 50%;
  404. }
  405. }
  406. .form-content {
  407. width: 100%;
  408. max-width: 500px;
  409. margin-bottom: 1.5rem;
  410. p {
  411. line-height: 2;
  412. letter-spacing: 1px;
  413. }
  414. }
  415. .alert-item {
  416. position: absolute;
  417. top: 50%;
  418. left: 50%;
  419. z-index: 100;
  420. transform: translate(-50%, -50%);
  421. .v-alert {
  422. width: 100%;
  423. padding: 1rem;
  424. .v-alert-title {
  425. margin-bottom: 0.5rem;
  426. }
  427. }
  428. }
  429. .prompt-text {
  430. color: #939393;
  431. }
  432. .vlog-item {
  433. .v-input {
  434. margin: 1.5rem auto 1rem;
  435. }
  436. h4 {
  437. position: relative;
  438. text-align: center;
  439. margin: 3rem auto 1rem;
  440. color: var(--main-color);
  441. letter-spacing: 2px;
  442. span {
  443. display: inline-block;
  444. font-weight: 600;
  445. font-size: 1rem;
  446. background-color: #fff;
  447. }
  448. }
  449. h4::before,
  450. h4::after {
  451. content: "";
  452. position: absolute;
  453. z-index: -1;
  454. top: 50%;
  455. width: 30%;
  456. height: 1px;
  457. background-color: var(--main-color);
  458. }
  459. h4::before {
  460. left: 0; /* 將左邊的線對齊到左側 */
  461. }
  462. h4::after {
  463. right: 0; /* 將右邊的線對齊到右側 */
  464. }
  465. }
  466. .vlog-alert {
  467. position: absolute;
  468. top: 50%;
  469. left: 50%;
  470. transform: translate(-50%, -50%);
  471. }
  472. </style>