Browse Source

文章手动校对字幕功能

natasha 1 month ago
parent
commit
e3c14040bc

+ 12 - 1
src/views/book/courseware/create/components/question/article/Article.vue

@@ -356,6 +356,13 @@
         :option-list="subtitleList"
         @saveSubtitles="saveSubtitles"
       />
+      <CheckSubtitlesSentence
+        v-if="visibleSentce"
+        :visible.sync="visibleSentce"
+        :audio-id="data.mp3_list[0] && data.mp3_list[0].file_id ? data.mp3_list[0].file_id : ''"
+        :option-list="subtitleList"
+        @saveSubtitles="saveSubtitles"
+      />
       <!-- 关联注释 -->
       <el-dialog
         v-if="matchNoteFlag"
@@ -404,6 +411,7 @@ import NewWord from './NewWord.vue';
 import Notes from './Notes.vue';
 import CheckPic from './CheckPic.vue';
 import CheckSubtitles from '@/views/book/courseware/create/components/question/voice_matrix/CheckSubtitles.vue';
+import CheckSubtitlesSentence from './CheckSubtitlesSentence.vue';
 import ResourcesOperate from '../../common/ResourcesOperate.vue';
 
 import { getArticleData } from '@/views/book/courseware/data/article';
@@ -435,6 +443,7 @@ export default {
     CheckPic,
     CheckSubtitles,
     ResourcesOperate,
+    CheckSubtitlesSentence,
   },
   mixins: [ModuleMixin],
   data() {
@@ -457,6 +466,7 @@ export default {
       multilingualText: '',
       autoLoading: false,
       visible: false,
+      visibleSentce: false,
       subtitleList: [],
       itemActive: null,
       itemActiveIndex: null,
@@ -1193,6 +1203,7 @@ export default {
           };
           this.subtitleList.push(obj);
         });
+        this.visibleSentce = true;
       } else {
         this.compareIndex = index;
         this.data.wordTime[index].wordsResultList.forEach((item, index) => {
@@ -1205,8 +1216,8 @@ export default {
           };
           this.subtitleList.push(obj);
         });
+        this.visible = true;
       }
-      this.visible = true;
     },
     /**
      * 保存字幕

+ 521 - 0
src/views/book/courseware/create/components/question/article/CheckSubtitlesSentence.vue

@@ -0,0 +1,521 @@
+<template>
+  <el-dialog :visible="visible" title="校对字幕" width="70%" :close-on-click-modal="false" @close="dialogClose">
+    <div class="dialog-container">
+      <div class="lrc">
+        <template v-for="(list, i) in dataList">
+          <div v-for="({ content, mark, lrc_data }, j) in list" :key="mark" class="lrc-item">
+            <div class="lrc-mark" @click="selectLrcItem(i, j, lrc_data.begin_time / 1000)">
+              <span :class="[lrcIndex.row === i && lrcIndex.col === j ? 'triangle' : 'lrc-line']"></span>
+            </div>
+            <div class="lrc-time">
+              <span
+                >[
+                <el-input
+                  :class="[lrcIndex.row === i && lrcIndex.col === j && inputIndex === 0 ? 'active' : '']"
+                  v-model="lrc_data.show_begin_time"
+                  type="text"
+                  @blur="handleBlur(i, j, 'bg')"
+                ></el-input>
+                ]</span
+              >
+            </div>
+            <div class="lrc-time">
+              <span
+                >[<el-input
+                  :class="[lrcIndex.row === i && lrcIndex.col === j && inputIndex === 1 ? 'active' : '']"
+                  v-model="lrc_data.show_end_time"
+                  type="text"
+                  @blur="handleBlur(i, j, 'ed')"
+                ></el-input
+                >]</span
+              >
+            </div>
+            <!-- eslint-disable-next-line vue/no-v-html -->
+            <span class="lrc-text" v-html="content" @click="selectLrcItem(i, j, lrc_data.begin_time / 1000)"></span>
+          </div>
+        </template>
+      </div>
+
+      <div class="audio-player">
+        <!-- <div class="player-line">
+          <div
+            class="progress-bar"
+            :style="{ width: Math.min((audio.current_time / audio.max_time) * 100, 100) + '%' }"
+          ></div>
+          <div class="progress-dot"></div>
+        </div> -->
+        <div class="player-line">
+          <el-slider
+            v-model="play_value"
+            :style="{ width: '100%', height: '3px' }"
+            :format-tooltip="formatProcessToolTip"
+            @change="changeCurrentTime"
+          />
+        </div>
+        <div class="player-controls">
+          <div class="timer">
+            <span>{{ secondFormatConversion(audio.current_time) }}</span> / <span>{{ audio_allTime }}</span>
+          </div>
+          <div class="svg-btn">
+            <SvgIcon :icon-class="iconClass" size="16" @click="playAudio" />
+          </div>
+        </div>
+      </div>
+
+      <div class="controls">
+        <span class="tips">播放时按空格进行打点</span>
+        <el-button type="primary" @click="saveSubtitles">保存</el-button>
+      </div>
+    </div>
+
+    <audio
+      :id="audioId"
+      :ref="audioId"
+      :src="url"
+      preload="metadata"
+      @loadedmetadata="onLoadedmetadata"
+      @timeupdate="onTimeupdate"
+      @canplaythrough="oncanplaythrough"
+      @pause="onPause"
+      @play="onPlay"
+    ></audio>
+  </el-dialog>
+</template>
+
+<script>
+import { GetFileURLMap } from '@/api/app';
+import { secondFormatConversion, audioTimeToMMSS } from '@/utils/transform';
+import { getArrayDepth } from '@/utils/common';
+
+export default {
+  name: 'CheckSubtitles',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false,
+    },
+    audioId: {
+      type: String,
+      required: true,
+    },
+    optionList: {
+      type: Array,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      url: '',
+      audio: {
+        paused: true,
+        playing: false,
+        // 音频当前播放时长
+        current_time: 0,
+        // 音频最大播放时长
+        max_time: 0,
+        isPlaying: false,
+        loading: false,
+      },
+      play_value: 0,
+      dataList: [],
+      lrcIndex: {
+        row: 0,
+        col: 0,
+      },
+      audio_allTime: null, // 展示总时间
+      secondFormatConversion,
+      audioTimeToMMSS,
+      getArrayDepth,
+      inputIndex: 0, // 开始时间/结束时间索引
+    };
+  },
+  computed: {
+    iconClass() {
+      return this.audio.paused ? 'paused' : 'playing';
+    },
+  },
+  watch: {
+    audioId: {
+      handler(val) {
+        if (!val) return;
+        GetFileURLMap({ file_id_list: [val] }).then(({ url_map }) => {
+          this.url = url_map[val];
+        });
+      },
+      immediate: true,
+    },
+    visible: {
+      handler(val) {
+        if (!val) {
+          this.$refs[this.audioId].pause();
+          this.lrcIndex = {
+            row: 0,
+            col: 0,
+          };
+        }
+      },
+    },
+    optionList: {
+      handler(val) {
+        let _val = JSON.parse(JSON.stringify(val));
+        this.dataList = this.getArrayDepth(_val) === 1 ? [_val] : _val;
+        console.log(this.dataList);
+        this.dataList.forEach((list) => {
+          list.forEach((item) => {
+            this.$set(item.lrc_data, 'show_begin_time', this.audioTimeToMMSS(item.lrc_data.begin_time / 1000));
+            this.$set(item.lrc_data, 'show_end_time', this.audioTimeToMMSS(item.lrc_data.end_time / 1000));
+          });
+        });
+      },
+      immediate: true,
+      deep: true,
+    },
+  },
+  mounted() {
+    window.addEventListener('keydown', this.handleKeydown);
+  },
+  beforeDestroy() {
+    window.removeEventListener('keydown', this.handleKeydown);
+  },
+  methods: {
+    dialogClose() {
+      this.$emit('update:visible', false);
+    },
+    /**
+     * 处理键盘按下事件
+     */
+    handleKeydown(event) {
+      if (!this.visible) return;
+      if (event.code === 'Space') {
+        event.preventDefault();
+        // 获取当前播放时间
+        const audio = this.$refs[this.audioId];
+        if (audio) {
+          const currentTime = audio.currentTime * 1000; // 转为毫秒
+          let obj = {
+            begin_time: currentTime,
+            end_time: currentTime + 1,
+            text: '',
+            show_begin_time: this.audioTimeToMMSS(currentTime / 1000),
+            show_end_time: this.audioTimeToMMSS(currentTime + 1 / 1000),
+          };
+          const { row, col } = this.lrcIndex;
+          if (row === -1) {
+            this.$message.warning('没有可用的标记位置');
+            const { begin_time, end_time } =
+              this.dataList[this.dataList.length - 1][this.dataList[this.dataList.length - 1].length - 1].lrc_data;
+
+            // 判断最后一个标记是否有效,如果无效则更新最后一个标记的结束时间
+            if (begin_time + 1 === end_time) {
+              this.dataList[this.dataList.length - 1][
+                this.dataList[this.dataList.length - 1].length - 1
+              ].lrc_data.end_time = currentTime;
+              this.dataList[this.dataList.length - 1][
+                this.dataList[this.dataList.length - 1].length - 1
+              ].lrc_data.show_end_time = this.audioTimeToMMSS(currentTime / 1000);
+            }
+
+            this.lrcIndex = {
+              row: -1,
+              col: -1,
+            };
+            return;
+          }
+          if (this.inputIndex === 0) {
+            this.dataList[row][col].lrc_data.begin_time = currentTime;
+            this.dataList[row][col].lrc_data.show_begin_time = this.audioTimeToMMSS(currentTime / 1000);
+            this.inputIndex = 1;
+          } else {
+            this.dataList[row][col].lrc_data.end_time = currentTime;
+            this.dataList[row][col].lrc_data.show_end_time = this.audioTimeToMMSS(currentTime / 1000);
+            this.inputIndex = 0;
+            let maxCol = this.dataList[row].length - 1;
+            let maxRow = this.dataList.length - 1;
+            let nextCol = col + 1;
+            let nextRow = row;
+            if (nextCol > maxCol) {
+              nextCol = 0;
+              nextRow += 1;
+              if (nextRow > maxRow) {
+                nextRow = -1;
+              }
+            }
+            // 更新当前标记的索引
+            this.lrcIndex = {
+              row: nextRow,
+              col: nextCol,
+            };
+          }
+          // this.dataList[row][col].lrc_data = obj;
+          // // 并将上一个标记的结束时间设置为当前时间
+          // if (col > 0) {
+          //   this.dataList[row][col - 1].lrc_data.end_time = currentTime;
+          //   this.dataList[row][col - 1].lrc_data.show_end_time = this.audioTimeToMMSS(currentTime / 1000);
+          // }
+          // if (col === 0 && row > 0) {
+          //   this.dataList[row - 1][this.dataList[row - 1].length - 1].lrc_data.end_time = currentTime;
+          //   this.dataList[row - 1][this.dataList[row - 1].length - 1].lrc_data.show_end_time = this.audioTimeToMMSS(
+          //     currentTime / 1000,
+          //   );
+          // }
+        }
+      }
+    },
+    selectLrcItem(i, j, time) {
+      this.lrcIndex = {
+        row: i,
+        col: j,
+      };
+      this.audio.current_time = time;
+      this.changeCurrentTime((time / this.audio.max_time) * 100);
+      this.inputIndex = 0;
+    },
+    playAudio() {
+      if (!this.url) return;
+      const audio = this.$refs[this.audioId];
+      // 暂停其他音频
+      let audioArr = document.getElementsByTagName('audio');
+      if (audioArr && audioArr.length > 0) {
+        for (let i = 0; i < audioArr.length; i++) {
+          if (audioArr[i].src === this.url) {
+            if (audioArr[i].id !== this.audioId) {
+              audioArr[i].pause();
+            }
+          } else {
+            audioArr[i].pause();
+          }
+        }
+      }
+      audio.paused ? audio.play() : audio.pause();
+    },
+    // 点击 拖拽播放音频
+    changeCurrentTime(value) {
+      let audioId = this.audioId;
+      this.$refs[audioId].currentTime = parseInt((value / 100) * this.audio.max_time);
+      this.$refs[audioId].play();
+      this.audio.playing = true;
+    },
+    // 进度条格式化toolTip
+    formatProcessToolTip(index) {
+      return this.audioTimeToMMSS(parseInt((this.audio.max_time / 100) * index));
+    },
+    /**
+     * 音频元数据加载完成
+     */
+    onLoadedmetadata(res) {
+      this.audio.max_time = parseInt(res.target.duration);
+      this.audio_allTime = secondFormatConversion(this.audio.max_time);
+    },
+    onTimeupdate(res) {
+      this.audio.current_time = res.target.currentTime;
+      this.play_value = (this.audio.current_time / this.audio.max_time) * 100;
+      if (this.audio.current_time * 1000 > this.ed) {
+        this.$refs[this.audioId].pause();
+      }
+    },
+    oncanplaythrough() {
+      this.audio.loading = false;
+    },
+    onPause() {
+      this.audio.paused = true;
+    },
+    onPlay() {
+      this.audio.paused = false;
+    },
+    saveSubtitles() {
+      const subtitles = this.getArrayDepth(this.optionList) === 1 ? this.dataList.flat() : this.dataList;
+      this.$emit('saveSubtitles', subtitles);
+      this.$message.success('字幕已保存');
+    },
+    // 手动修改时间
+    handleBlur(i, j, type) {
+      let show_value = null;
+      if (type === 'bg') {
+        show_value = this.dataList[i][j].lrc_data.show_begin_time;
+      } else {
+        show_value = this.dataList[i][j].lrc_data.show_end_time;
+      }
+      let arr = show_value.split(':');
+      if (arr.length === 3) {
+        let new_value = (arr[0] * 60 + arr[1] * 1) * 1000 + arr[2].substring(0, 3).padEnd(3, '0') * 1; // 毫秒只保留3位 不足补0
+        if (type === 'bg') {
+          this.dataList[i][j].lrc_data.begin_time = new_value;
+          this.dataList[i][j].lrc_data.show_begin_time = this.audioTimeToMMSS(
+            this.dataList[i][j].lrc_data.begin_time / 1000,
+          );
+        } else {
+          this.dataList[i][j].lrc_data.end_time = new_value;
+          this.dataList[i][j].lrc_data.show_end_time = this.audioTimeToMMSS(
+            this.dataList[i][j].lrc_data.end_time / 1000,
+          );
+        }
+      } else {
+        this.$message.warning('字幕时间应为00:00:000格式,请重新输入');
+        if (type === 'bg') {
+          this.dataList[i][j].lrc_data.show_begin_time = this.audioTimeToMMSS(
+            this.dataList[i][j].lrc_data.begin_time / 1000,
+          );
+        } else {
+          this.dataList[i][j].lrc_data.show_end_time = this.audioTimeToMMSS(
+            this.dataList[i][j].lrc_data.end_time / 1000,
+          );
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.dialog-container {
+  display: flex;
+  flex-direction: column;
+  row-gap: 8px;
+
+  > div {
+    padding: 8px 4px;
+  }
+
+  .lrc {
+    height: 280px;
+    overflow-y: auto;
+
+    &-item {
+      display: flex;
+      column-gap: 8px;
+      align-items: stretch;
+
+      .lrc-mark {
+        display: flex;
+        align-items: center;
+        cursor: pointer;
+
+        .triangle {
+          width: 0;
+          height: 0;
+          border-top: 4px solid transparent;
+          border-right: 0 solid transparent;
+          border-bottom: 4px solid transparent;
+          border-left: 8px solid $main-color;
+        }
+
+        .lrc-line {
+          width: 6px;
+          height: 1px;
+          background-color: #666; // 灰色线条
+        }
+      }
+
+      .lrc-time {
+        display: flex;
+        align-items: center;
+        min-width: 85px;
+
+        span {
+          display: flex;
+
+          :deep .el-input {
+            width: 75px;
+
+            .el-input__inner {
+              height: 20px;
+              padding: 0 1px;
+              line-height: 20px;
+              background-color: transparent;
+              border: none;
+            }
+          }
+
+          .active {
+            :deep .el-input__inner {
+              background-color: #bfcef2;
+            }
+          }
+        }
+      }
+
+      .lrc-text {
+        :deep p {
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  .audio-player {
+    display: flex;
+    flex-direction: column;
+    row-gap: 16px;
+    color: #000;
+    background-color: #f2f3f5;
+    border: $border;
+    border-radius: 4px;
+
+    .player-line {
+      width: 100%;
+      height: 3px;
+
+      // background-color: #ccc;
+
+      .progress-bar {
+        position: relative;
+        width: 0%;
+        max-width: 540px;
+        height: 100%;
+        background-color: $main-color;
+
+        &::after {
+          position: absolute;
+          top: -2px;
+          right: -4px;
+          display: inline-block;
+          width: 8px;
+          height: 8px;
+          content: '';
+          background-color: $main-color;
+          border-radius: 50%;
+        }
+      }
+    }
+
+    .player-controls {
+      display: flex;
+      align-items: center;
+
+      svg {
+        cursor: pointer;
+      }
+
+      .svg-btn {
+        display: flex;
+        flex: 1;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  .controls {
+    display: flex;
+    align-items: center;
+    background-color: #f2f3f5;
+    border: $border;
+    border-radius: 4px;
+
+    .tips {
+      flex: 1;
+      text-align: center;
+    }
+  }
+
+  :deep .el-slider__runway {
+    height: 6px;
+    padding: 0;
+    margin: 0;
+    background: #e5e5e5;
+    border-radius: 0;
+  }
+}
+</style>

+ 12 - 1
src/views/book/courseware/create/components/question/dialogue_article/Article.vue

@@ -545,6 +545,13 @@
         :option-list="subtitleList"
         @saveSubtitles="saveSubtitles"
       />
+      <CheckSubtitlesSentence
+        v-if="visibleSentce"
+        :visible.sync="visibleSentce"
+        :audio-id="data.mp3_list[0] && data.mp3_list[0].file_id ? data.mp3_list[0].file_id : ''"
+        :option-list="subtitleList"
+        @saveSubtitles="saveSubtitles"
+      />
       <!-- 关联注释 -->
       <el-dialog
         v-if="matchNoteFlag"
@@ -592,6 +599,7 @@ import CompareTime from '../article/CompareTime.vue';
 import NewWord from '../article/NewWord.vue';
 import Notes from '../article/Notes.vue';
 import CheckSubtitles from '@/views/book/courseware/create/components/question/voice_matrix/CheckSubtitles.vue';
+import CheckSubtitlesSentence from '../article/CheckSubtitlesSentence.vue';
 import ResourcesOperate from '../../common/ResourcesOperate.vue';
 
 import { getArticleData, roleDefaultColorList } from '@/views/book/courseware/data/dialogueArticle';
@@ -612,6 +620,7 @@ export default {
     Notes,
     CheckSubtitles,
     ResourcesOperate,
+    CheckSubtitlesSentence,
   },
   mixins: [ModuleMixin],
   data() {
@@ -637,6 +646,7 @@ export default {
       multilingualText: '',
       autoLoading: false,
       visible: false,
+      visibleSentce: false,
       subtitleList: [],
       itemActive: null,
       itemActiveIndex: null,
@@ -1641,6 +1651,7 @@ export default {
           };
           this.subtitleList.push(obj);
         });
+        this.visibleSentce = true;
       } else {
         this.compareIndex = index;
         this.data.wordTime[index].wordsResultList.forEach((item, index) => {
@@ -1653,8 +1664,8 @@ export default {
           };
           this.subtitleList.push(obj);
         });
+        this.visible = true;
       }
-      this.visible = true;
     },
     /**
      * 保存字幕