FaceRecognition.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. <template>
  2. <div>
  3. <video
  4. id="video"
  5. ref="video"
  6. :width="width"
  7. :height="height"
  8. autoplay
  9. ></video>
  10. <div
  11. v-if="showRecognizeButton"
  12. style="position: absolute; width: 400px; text-align: center; margin-top: -50px; color: #232323;"
  13. >
  14. <button
  15. :class="['verify-button', disableSnap && 'disable-verify-button']"
  16. :disabled="disableSnap"
  17. @click="snap"
  18. >
  19. {{ msg }}
  20. </button>
  21. </div>
  22. </div>
  23. </template>
  24. <script>
  25. import MD5 from "js-md5";
  26. import { mapState as globalMapState } from "vuex";
  27. import { createNamespacedHelpers } from "vuex";
  28. const { mapState, mapMutations } = createNamespacedHelpers("examingHomeModule");
  29. import { chromeUA } from "@/utils/ua.js";
  30. export default {
  31. name: "FaceRecognition",
  32. props: {
  33. width: { type: String, default: "400" },
  34. height: { type: String, default: "300" },
  35. showRecognizeButton: Boolean,
  36. closeCamera: Boolean, // optional
  37. },
  38. data() {
  39. return { disableSnap: true, msg: "开始识别" };
  40. },
  41. computed: {
  42. ...globalMapState(["user"]),
  43. ...mapState(["snapNow"]),
  44. },
  45. watch: {
  46. snapNow(val) {
  47. if (val) {
  48. if (!this.lastSnapTime || Date.now() - this.lastSnapTime > 20 * 1000) {
  49. this.lastSnapTime = Date.now();
  50. this.snapTimer();
  51. } else {
  52. // this.serverLog(
  53. // "debug/S-002001",
  54. // "上次的抓拍未超过1分钟,本次抓拍指令取消"
  55. // );
  56. window._hmt.push([
  57. "_trackEvent",
  58. "摄像头框",
  59. "上次的抓拍未超过1分钟,本次抓拍指令取消",
  60. ]);
  61. this.decreaseSnapCount();
  62. }
  63. this.toggleSnapNow();
  64. }
  65. },
  66. closeCamera: function(newValue) {
  67. if (newValue) {
  68. console.log("关闭摄像头");
  69. window.__stream = null;
  70. if (this.$refs.video.srcObject) {
  71. this.$refs.video.srcObject.getTracks().forEach(function(track) {
  72. track.stop();
  73. });
  74. this.$refs.video.srcObject = null;
  75. }
  76. } else {
  77. this.openCamera();
  78. }
  79. },
  80. },
  81. async mounted() {
  82. window.__stream = null;
  83. this.openCamera();
  84. // this.checkFaceDetectorTimeout = setTimeout(() => {
  85. // this.nativeFaceDetectorStats();
  86. // }, 60 * 1000);
  87. },
  88. beforeDestroy() {
  89. clearTimeout(this.retrySnapTimeout);
  90. clearTimeout(this.showSnapResultTimeout);
  91. // clearTimeout(this.checkFaceDetectorTimeout);
  92. window.__stream = null;
  93. if (this.$refs.video.srcObject) {
  94. this.$refs.video.srcObject.getTracks().forEach(function(track) {
  95. track.stop();
  96. });
  97. this.$refs.video.srcObject = null;
  98. }
  99. },
  100. methods: {
  101. ...mapMutations(["toggleSnapNow", "decreaseSnapCount"]),
  102. async openCamera() {
  103. const _openStartTime = Date.now();
  104. const video = this.$refs.video;
  105. if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  106. try {
  107. console.log("启动摄像头");
  108. console.log({
  109. getSupportedConstraints:
  110. navigator.mediaDevices.getSupportedConstraints &&
  111. navigator.mediaDevices.getSupportedConstraints(),
  112. });
  113. const stream = await navigator.mediaDevices.getUserMedia({
  114. video: {
  115. facingMode: "user",
  116. aspectRatio: 4 / 3,
  117. resizeMode: "crop-and-scale",
  118. width: 640,
  119. height: 480,
  120. },
  121. });
  122. if (stream) {
  123. {
  124. const vt0 = stream.getVideoTracks()[0];
  125. if (
  126. vt0 &&
  127. vt0.getCapabilities &&
  128. vt0.getConstraints &&
  129. vt0.getSettings
  130. ) {
  131. console.log({
  132. getCapabilities: vt0.getCapabilities && vt0.getCapabilities(),
  133. getConstraints: vt0.getConstraints && vt0.getConstraints(),
  134. getSettings: vt0.getSettings && vt0.getSettings(),
  135. });
  136. } else {
  137. console.log("stream.getVideoTracks()[0] failed");
  138. }
  139. }
  140. video.srcObject = stream;
  141. window.__stream = stream;
  142. try {
  143. try {
  144. await video.play();
  145. } catch (error) {
  146. if (error.name == "AbortError") {
  147. console.log("AbortError and retry");
  148. await video.play();
  149. window._hmt.push([
  150. "_trackEvent",
  151. "摄像头框",
  152. "摄像头状态",
  153. "摄像头没有正常启用: AbortError 重试成功",
  154. ]);
  155. } else {
  156. throw error;
  157. }
  158. }
  159. this.disableSnap = false;
  160. const _openEndTime = Date.now();
  161. window._hmt.push([
  162. "_trackEvent",
  163. "摄像头框",
  164. "摄像头打开耗时",
  165. Number(
  166. Math.floor((_openEndTime - _openStartTime) / 1000)
  167. ).toPrecision(1) + "秒",
  168. ]);
  169. console.log(
  170. "摄像头打开耗时",
  171. Math.floor((_openEndTime - _openStartTime) / 1000) + "秒"
  172. );
  173. } catch (error) {
  174. console.log("摄像头没有正常启用", error);
  175. this.$Message.error({
  176. content: "摄像头没有正常启用: " + error,
  177. duration: 15,
  178. closable: true,
  179. });
  180. window._hmt.push([
  181. "_trackEvent",
  182. "摄像头框",
  183. "摄像头状态",
  184. "摄像头没有正常启用: " + error,
  185. ]);
  186. }
  187. } else {
  188. this.$Message.error({
  189. content: "没有可用的视频流",
  190. duration: 15,
  191. closable: true,
  192. });
  193. window._hmt.push([
  194. "_trackEvent",
  195. "摄像头框",
  196. "摄像头状态",
  197. "没有可用的视频流",
  198. ]);
  199. }
  200. } catch (error) {
  201. console.log("无法启用摄像头", error);
  202. let errMsg;
  203. if (error.name && error.message) {
  204. errMsg = `${error.name} ${error.message}`;
  205. } else {
  206. errMsg = error;
  207. }
  208. if (error.name === "NotReadableError") {
  209. this.$Message.error({
  210. content: "无法启用摄像头: " + error.name + " 请重试!",
  211. duration: 15,
  212. closable: true,
  213. });
  214. } else if (error.name === "NotFoundError") {
  215. this.$Message.error({
  216. content:
  217. "无法启用摄像头: " +
  218. error.name +
  219. " 没有找到合适的摄像头!请重试或更换摄像头!",
  220. duration: 15,
  221. closable: true,
  222. });
  223. } else {
  224. this.$Message.error({
  225. content: "无法启用摄像头: " + errMsg,
  226. duration: 15,
  227. closable: true,
  228. });
  229. }
  230. window._hmt.push([
  231. "_trackEvent",
  232. "摄像头框",
  233. "摄像头状态",
  234. "无法启用摄像头" +
  235. errMsg +
  236. (typeof errMsg === "object" ? JSON.stringify(errMsg) : ""),
  237. ]);
  238. }
  239. } else {
  240. this.$Message.error({
  241. content: "没有找到可用的摄像头",
  242. duration: 15,
  243. closable: true,
  244. });
  245. window._hmt.push([
  246. "_trackEvent",
  247. "摄像头框",
  248. "摄像头状态",
  249. "没有找到可用的摄像头",
  250. ]);
  251. }
  252. },
  253. async snapTimer() {
  254. try {
  255. const examRecordDataId = this.$route.params.examRecordDataId;
  256. const captureBlob = await this.getSnapShot({ compareSync: false });
  257. this.videoStartPlay();
  258. console.log("抓拍照片的大小:" + captureBlob.size);
  259. if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
  260. // 经查以前记录,不完整图片均为8192大小。此处设置小于10KB的图片为未抓拍成功
  261. // 检查百度统计的记录后,这里的图片大小可能小于8192,也可能是有效的数据,所以降低图片大小的要求为face++的要求
  262. window._hmt.push([
  263. "_trackEvent",
  264. "摄像头框",
  265. "定时抓拍照片大小异常" + chromeUA.major,
  266. captureBlob.size,
  267. ]);
  268. throw "定时抓拍照片大小异常";
  269. }
  270. // this.serverLog("debug/S-004001", "抓拍照片的大小:" + captureBlob.size);
  271. const [captureFilePath, signIdentifier] = await this.uploadToServer(
  272. captureBlob
  273. );
  274. await this.faceCompare(
  275. captureFilePath,
  276. signIdentifier,
  277. examRecordDataId
  278. );
  279. console.log("定时抓拍流程成功", signIdentifier);
  280. } catch (error) {
  281. console.log("定时抓拍流程失败", error);
  282. window._hmt.push([
  283. "_trackEvent",
  284. "摄像头框",
  285. "定时抓拍流程失败" + error,
  286. (this.lastSnapTime ? "(非初次抓拍)" : "") + "将再次抓拍",
  287. ]);
  288. this.retrySnapTimeout = setTimeout(() => {
  289. this.toggleSnapNow();
  290. }, 60 * 1000);
  291. } finally {
  292. this.videoStartPlay();
  293. this.decreaseSnapCount();
  294. }
  295. },
  296. videoStartPlay() {
  297. const video = this.$refs.video;
  298. video && video.play();
  299. },
  300. async snap() {
  301. // TODO: chrome 70. FaceDetector检测人脸
  302. // var canvas = document.createElement("canvas");
  303. // canvas.width = 220;
  304. // canvas.height = 165;
  305. // var context = canvas.getContext("2d");
  306. // context.drawImage(this.$refs.video, 0, 0, 220, 165);
  307. // var f = new FaceDetector();
  308. // const v = await f.detect(canvas);
  309. // console.log(v);
  310. // return;
  311. this.$Message.destroy();
  312. try {
  313. this.disableSnap = true;
  314. // console.log("disableSnap: " + this.disableSnap);
  315. // await new Promise(resolve =>
  316. // setTimeout(() => {
  317. // console.log(new Date());
  318. // resolve();
  319. // }, 3000)
  320. // );
  321. // return;
  322. // if(this.disableSnap) return; // 避免界面没有更新。
  323. this.msg = "拍照中...";
  324. const captureBlob = await this.getSnapShot({ compareSync: true });
  325. console.log("抓拍照片大小", captureBlob.size);
  326. if (captureBlob.size < 48 * 48 || captureBlob.size >= 2 * 1024 * 1024) {
  327. this.$Message.error({
  328. content: "抓拍照片太小!",
  329. duration: 15,
  330. closable: true,
  331. });
  332. // 经查以前记录,不完整图片均为8192大小。此处设置小于10KB的图片为未抓拍成功
  333. // 检查百度统计的记录后,这里的图片大小可能小于8192,也可能是有效的数据,所以降低图片大小的要求为face++的要求
  334. window._hmt.push([
  335. "_trackEvent",
  336. "摄像头框",
  337. "抓拍照片大小异常" + chromeUA.major,
  338. captureBlob.size,
  339. ]);
  340. throw "抓拍照片大小异常";
  341. }
  342. this.videoStartPlay();
  343. this.msg = "上传照片中...";
  344. console.log(this.msg);
  345. const [captureFilePath, signIdentifier] = await this.uploadToServer(
  346. captureBlob
  347. );
  348. this.msg = "人脸比对中...";
  349. console.log(this.msg);
  350. await this.faceCompareSync(captureFilePath, signIdentifier);
  351. console.log("人脸比对成功");
  352. } catch (error) {
  353. console.log("同步照片比对流程失败");
  354. throw error;
  355. } finally {
  356. this.videoStartPlay();
  357. this.msg = "开始识别";
  358. this.disableSnap = false;
  359. }
  360. },
  361. async getSnapShot({ compareSync }) {
  362. return new Promise((resolve, reject) => {
  363. const video = this.$refs.video;
  364. if (video.readyState !== 4 || !video.srcObject.active) {
  365. this.$Message.error({
  366. content: "摄像头没有正常启用",
  367. duration: 5,
  368. closable: true,
  369. });
  370. window._hmt.push([
  371. "_trackEvent",
  372. "摄像头框",
  373. "摄像头状态",
  374. "摄像头没有正常启用" +
  375. (!compareSync && this.lastSnapTime ? "-退出(非初次抓拍)" : ""),
  376. ]);
  377. reject("摄像头没有正常启用");
  378. if (!compareSync) {
  379. this.logout(
  380. "?LogoutReason=" +
  381. "摄像头没有正常启用" +
  382. (!compareSync && this.lastSnapTime ? "-退出(非初次抓拍)" : "")
  383. );
  384. }
  385. return;
  386. }
  387. video.pause();
  388. var canvas = document.createElement("canvas");
  389. canvas.width = 220;
  390. canvas.height = 165;
  391. var context = canvas.getContext("2d");
  392. context.drawImage(video, 0, 0, 220, 165);
  393. canvas.toBlob(resolve, "image/png", 0.95);
  394. });
  395. },
  396. async uploadToServer(captureBlob) {
  397. async function blobToArray(blob) {
  398. return new Promise(resolve => {
  399. var reader = new FileReader();
  400. reader.addEventListener("loadend", function() {
  401. // reader.result contains the contents of blob as a typed array
  402. resolve(reader.result);
  403. });
  404. reader.readAsArrayBuffer(blob);
  405. });
  406. }
  407. //保存抓拍照片到服务器
  408. let resultUrl, signIdentifier;
  409. try {
  410. const buffer = await blobToArray(captureBlob);
  411. // console.log(buffer);
  412. // var view1 = new Uint8Array(buffer);
  413. // console.log(buffer[0], buffer[1], buffer[429721]);
  414. const fileMd5 = MD5(buffer);
  415. console.log(fileMd5);
  416. const params = new URLSearchParams();
  417. params.append("fileSuffix", "png");
  418. params.append("fileMd5", fileMd5);
  419. const res = await this.$http.get(
  420. "/api/ecs_oe_student/examControl/getCapturePhotoUpYunSign?" + params
  421. );
  422. // console.log(res);
  423. // let myHeaders = new Headers();
  424. // for (let [k, v] of Object.entries(res.data.headers)) {
  425. // // console.log(k, v);
  426. // if (k.includes("tion") || k.includes("Date") || k.includes("MD5")) {
  427. // if (k === "Date") k = "x-date";
  428. // myHeaders.append(k, v);
  429. // }
  430. let myFormData = new FormData();
  431. for (let [k, v] of Object.entries(res.data.formParams)) {
  432. myFormData.append(k, v);
  433. }
  434. myFormData.append("file", captureBlob);
  435. try {
  436. const res2 = await fetch(res.data.formUrl, {
  437. method: "POST",
  438. body: myFormData,
  439. });
  440. if (!res2.ok) {
  441. throw res2.status;
  442. }
  443. } catch (error) {
  444. window._hmt.push([
  445. "_trackEvent",
  446. "摄像头框",
  447. "抓拍照片保存失败--upyun",
  448. error,
  449. ]);
  450. throw error;
  451. }
  452. // console.log(response);
  453. resultUrl = res.data.accessUrl;
  454. signIdentifier = res.data.signIdentifier;
  455. // this.serverLog("debug/S-005001", "抓拍照片保存成功:");
  456. window._hmt.push(["_trackEvent", "摄像头框", "抓拍照片保存成功"]);
  457. } catch (e) {
  458. console.log(e);
  459. // this.serverLog("debug/S-006001", "抓拍照片保存失败");
  460. window._hmt.push([
  461. "_trackEvent",
  462. "摄像头框",
  463. "保存抓拍照片到服务器失败!",
  464. ]);
  465. this.$Message.error({
  466. content: "抓拍照片保存失败!",
  467. duration: 15,
  468. closable: true,
  469. });
  470. throw "抓拍照片保存失败!";
  471. }
  472. // let UPYUN_URL;
  473. // try {
  474. // UPYUN_URL = (await this.$http.get("/api/ecs_oe_student_face/upyun"))
  475. // .data.downloadPrefix;
  476. // } catch (error) {
  477. // this.$Message.error({ content: "获取照片下载前缀失败!", duration: 15, closable: true});
  478. // throw "获取照片下载前缀失败!";
  479. // }
  480. return [resultUrl, signIdentifier];
  481. },
  482. async faceCompareSync(captureFilePath, signIdentifier) {
  483. try {
  484. const res = await this.$http.post(
  485. "/api/ecs_oe_student_face/examCaptureQueue/compareFaceSync?signIdentifier=" +
  486. signIdentifier +
  487. "&fileUrl=" +
  488. encodeURIComponent(captureFilePath)
  489. );
  490. this.$emit("on-recognize-result", {
  491. error: null,
  492. pass: res.data.isPass,
  493. stranger: res.data.isStranger,
  494. });
  495. } catch (e) {
  496. console.log(e);
  497. // this.$Message.error(e.message);
  498. throw "同步照片比较失败!";
  499. }
  500. },
  501. async faceCompare(captureFilePath, signIdentifier, examRecordDataId) {
  502. try {
  503. let cameraInfos;
  504. let hasVirtualCamera = false;
  505. if (typeof nodeRequire != "undefined") {
  506. try {
  507. var fs = window.nodeRequire("fs");
  508. if (fs.existsSync("multiCamera.exe")) {
  509. await new Promise((resolve, reject) => {
  510. window.nodeRequire("node-cmd").get("multiCamera.exe", () => {
  511. try {
  512. cameraInfos = fs.readFileSync("CameraInfo.txt", "utf-8");
  513. if (cameraInfos && cameraInfos.trim()) {
  514. cameraInfos = cameraInfos.trim();
  515. cameraInfos = cameraInfos.replace(/\r\n/g, "");
  516. cameraInfos = cameraInfos.replace(/\n/g, "");
  517. console.log(cameraInfos);
  518. this.serverLog("debug/S-001001", cameraInfos);
  519. }
  520. if (cameraInfos.includes('""')) {
  521. hasVirtualCamera = true;
  522. }
  523. resolve();
  524. } catch (error) {
  525. window._hmt.push([
  526. "_trackEvent",
  527. "摄像头框",
  528. "虚拟摄像头-读取摄像头列表失败",
  529. ]);
  530. reject("读取摄像头列表失败");
  531. }
  532. });
  533. });
  534. }
  535. } catch (error) {
  536. console.log(error);
  537. }
  538. }
  539. let body = {
  540. fileUrl: captureFilePath,
  541. signIdentifier,
  542. examRecordDataId,
  543. };
  544. if (cameraInfos) {
  545. body.cameraInfos = cameraInfos;
  546. body.hasVirtualCamera = hasVirtualCamera;
  547. }
  548. const res = await this.$http.post(
  549. "/api/ecs_oe_student_face/examCaptureQueue/uploadExamCapture",
  550. body
  551. );
  552. const fileName = res.data;
  553. try {
  554. await this.showSnapResult(fileName, examRecordDataId);
  555. } catch (error) {
  556. this.$Message.error({
  557. content: "设置获取抓拍结果失败!",
  558. duration: 15,
  559. closable: true,
  560. });
  561. }
  562. } catch (e) {
  563. console.log(e);
  564. window._hmt.push([
  565. "_trackEvent",
  566. "摄像头框",
  567. "faceCompare失败",
  568. e.response ? e.response.data.desc : e,
  569. ]);
  570. // this.$Message.error(e.message);
  571. throw "异步比较抓拍照片失败";
  572. }
  573. },
  574. async showSnapResult(fileName, examRecordDataId) {
  575. if (!fileName) return; // 交卷后提交照片会得不到照片名称
  576. if (this.$route.name !== "OnlineExamingHome") {
  577. // 非考试页,不显示结果,也不继续查询
  578. return;
  579. }
  580. try {
  581. // 获取抓拍结果
  582. const snapRes =
  583. (await this.$http.get(
  584. "/api/ecs_oe_student_face/examCaptureQueue/getExamCaptureResult?fileName=" +
  585. fileName +
  586. "&examRecordDataId=" +
  587. examRecordDataId
  588. )).data || {};
  589. if (snapRes.isCompleted) {
  590. if (snapRes.isStranger) {
  591. this.$Message.error({
  592. content: "请独立完成考试",
  593. duration: 5,
  594. closable: true,
  595. });
  596. } else if (!snapRes.isPass) {
  597. this.$Message.error({
  598. content: "请调整坐姿,诚信考试",
  599. duration: 5,
  600. closable: true,
  601. });
  602. }
  603. } else {
  604. this.showSnapResultTimeout = setTimeout(
  605. this.showSnapResult.bind(this, fileName, examRecordDataId),
  606. 30 * 1000
  607. );
  608. }
  609. } catch (e) {
  610. console.log(e);
  611. if (this.$route.name !== "OnlineExamingHome") {
  612. // 非考试页,不显示结果,也不继续查询
  613. return;
  614. }
  615. this.$Message.error(e.message);
  616. throw e.message;
  617. }
  618. },
  619. async nativeFaceDetectorStats() {
  620. // 如果不存在 window.FaceDetector ,则不统计。chrome 76+ 在win7上是有window.FaceDetector的,但是不能执行成功。
  621. if (!window.FaceDetector) {
  622. window._hmt.push([
  623. "_trackEvent",
  624. "摄像头框",
  625. "native FaceDetector",
  626. "没有能力",
  627. ]);
  628. return;
  629. }
  630. try {
  631. var canvas = document.createElement("canvas");
  632. canvas.width = 220;
  633. canvas.height = 165;
  634. var context = canvas.getContext("2d");
  635. context.drawImage(this.$refs.video, 0, 0, 220, 165);
  636. var f = new window.FaceDetector();
  637. const v = await f.detect(canvas);
  638. console.log("nativeFaceDetector", v);
  639. window._hmt.push([
  640. "_trackEvent",
  641. "摄像头框",
  642. "native FaceDetector",
  643. "成功",
  644. ]);
  645. } catch (e) {
  646. console.log(e);
  647. window._hmt.push([
  648. "_trackEvent",
  649. "摄像头框",
  650. "native FaceDetector",
  651. "失败",
  652. ]);
  653. }
  654. },
  655. },
  656. };
  657. </script>
  658. <style scoped>
  659. .verify-button {
  660. font-size: 16px;
  661. background-color: #ffcc00;
  662. display: inline-block;
  663. padding: 6px 16px;
  664. border-radius: 6px;
  665. }
  666. .verify-button:hover {
  667. color: #444444;
  668. cursor: pointer;
  669. }
  670. .disable-verify-button {
  671. background-color: #f7f7f7;
  672. color: #c5c8ce;
  673. }
  674. .disable-verify-button:hover {
  675. cursor: not-allowed;
  676. color: #c5c8ce;
  677. }
  678. </style>