音视频处理:视频时间轴在指定时间处添加音频并展示可视化拖拽条

文章目录

    • 需求
    • 分析
      • [1. 获取视频链接的时长](#1. 获取视频链接的时长)
      • [2. 绘制刻度时间轴](#2. 绘制刻度时间轴)
      • [3. 添加音频轨道,并可拖拽移动,且鼠标抬起时输出当前位置](#3. 添加音频轨道,并可拖拽移动,且鼠标抬起时输出当前位置)
      • [4. 总代码](#4. 总代码)

需求

根据视频链接生成时间轴,并添加音频的对齐

分析

1. 获取视频链接的时长

javascript 复制代码
const durationVideo = ref<number | null>(null)
const errorMsg = ref('')

/**
 * 获取远程音视频的时长(单位:秒)
 * @param {string} videoUrl - 视频链接
 * @returns {Promise<number>} 视频时长(秒)
 */
const getVideoDurationInSeconds = async (videoUrl: string): Promise<number> => {
  return new Promise((resolve, reject) => {
    // 修复:创建新的video元素,避免影响页面中的播放器
    const video = document.createElement('video')
    video.crossOrigin = 'anonymous'
    video.onloadedmetadata = () => {
      const duration = Number(video.duration?.toFixed(0))
      // 释放资源
      video.removeAttribute('src')
      video.load()
      URL.revokeObjectURL(video.src)
      resolve(duration)
    }
    video.onerror = () => {
      reject(new Error('视频加载失败'))
      video.removeAttribute('src')
      video.load()
      URL.revokeObjectURL(video.src)
    }
    setTimeout(() => {
      reject(new Error('获取视频时长超时'))
    }, 10000)
    video.src = videoUrl
    video.load()
  })
}

2. 绘制刻度时间轴

javascript 复制代码
/**
 * 初始化时间标尺
 */
const audioItems = ref([]) // 音频数组
function initTimelineRuler() {
  const timelineRuler = document.getElementById('timelineRuler')
  if (!timelineRuler) return
  timelineRuler.innerHTML = ''

  // 获取视频时长(确保为有效正整数,默认60秒)
  const timelineDuration = Math.max(Number(durationVideo.value) || 60, 1) // 至少1秒
  // 最小刻度数量(视觉下限,避免刻度太少)
  const MIN_MARKS = 8
  // 最大刻度数量(视觉上限,避免刻度太密)
  const MAX_MARKS = 20
  // 新增:间隔的最小限制(1秒,且为整数)
  const MIN_INTERVAL = 1

  /**
   * 核心算法:计算最优刻度间隔(保证是整数,且≥1秒)
   * 思路:
   * 1. 先计算初始间隔(总时长 / 最大刻度数,保证不超最大数量),取整后至少为1秒
   * 2. 把间隔向上取整为「1、2、5、10、20、50、100...」这类易读的整数(人类视觉友好)
   * 3. 最终保证刻度覆盖0到总时长,且数量在MIN~MAX之间
   */
  function getOptimalInterval(total, minMarks, maxMarks, minInterval) {
    // 初始间隔:按最大刻度数平分总时长,取整后至少为最小间隔(1秒)
    let initialInterval = Math.max(Math.ceil(total / maxMarks), minInterval)
    // 易读的间隔基数(人类习惯的整数刻度单位,避免出现3、7这类不规则数)
    const baseIntervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]

    // 找到比初始间隔大的最小基数(比如初始间隔是8,取10;初始间隔是3,取5)
    let optimalInterval = baseIntervals.find((interval) => interval >= initialInterval)
    // 若初始间隔超过所有基数,取最大基数的整数倍(比如总时长1000,初始间隔50,取50)
    if (!optimalInterval) {
      const maxBase = baseIntervals[baseIntervals.length - 1]
      optimalInterval = Math.ceil(initialInterval / maxBase) * maxBase
    }

    // 校验刻度数量:如果按当前间隔计算的刻度数太少,缩小间隔(保证至少minMarks个,且不小于最小间隔)
    let markCount = Math.ceil(total / optimalInterval) + 1 // +1 包含0
    while (markCount < minMarks) {
      // 找到当前间隔的前一个更小的基数(比如当前是10,前一个是5)
      const currentIndex = baseIntervals.indexOf(optimalInterval)
      if (currentIndex > 0) {
        optimalInterval = baseIntervals[currentIndex - 1]
        // 确保缩小后的间隔不小于最小间隔(1秒)
        if (optimalInterval < minInterval) {
          optimalInterval = minInterval
          break
        }
      } else {
        // 已经是最小基数(1秒),无法再缩小,直接退出
        break
      }
      markCount = Math.ceil(total / optimalInterval) + 1
    }

    // 最终确保间隔是整数且≥最小间隔
    return Math.max(Math.round(optimalInterval), minInterval)
  }

  // 获取最优间隔(整数,≥1秒)
  const interval = getOptimalInterval(timelineDuration, MIN_MARKS, MAX_MARKS, MIN_INTERVAL)
  // 计算刻度的起始(0)和结束(总时长),以及所有中间刻度
  let time = 0
  // 循环生成刻度,直到超过总时长(最后一个刻度强制为总时长)
  while (time <= timelineDuration) {
    // 处理最后一个刻度:如果当前时间+间隔超过总时长,直接用总时长作为最后一个刻度
    let currentTime = time
    // 避免出现超过总时长的刻度,且保证currentTime是整数
    if (currentTime > timelineDuration) {
      currentTime = timelineDuration
    }
    // 强制转为整数(防止精度问题出现小数)
    currentTime = Math.floor(currentTime)

    // 创建刻度元素
    const mark = document.createElement('div')
    mark.className = 'ruler-mark'
    mark.style.left = `${(currentTime / timelineDuration) * 100}%`
    mark.setAttribute('data-time', `${currentTime}s`)
    timelineRuler.appendChild(mark)

    // 最后一个刻度如果是总时长,停止循环
    if (currentTime === timelineDuration) {
      break
    }

    // 增加间隔(确保是整数,避免小数)
    time += interval
    // 处理极端情况:如果time累加后变成小数(如精度问题),强制取整
    time = Math.floor(time)
  }

  // 兜底:如果上述循环未生成最后一个刻度(极端精度问题),手动添加总时长刻度
  const lastMark = timelineRuler.lastChild
  const lastMarkTime = lastMark ? Number(lastMark.getAttribute('data-time').replace('s', '')) : -1
  if (lastMarkTime !== timelineDuration) {
    const mark = document.createElement('div')
    mark.className = 'ruler-mark'
    mark.style.left = '100%'
    mark.setAttribute('data-time', `${timelineDuration}s`)
    timelineRuler.appendChild(mark)
  }
}
let flvPlayer: any = null
const handlePlay = () => {
  if (flvjs.isSupported()) {
    const videoElement = document.getElementById('player') as HTMLVideoElement
    // 直播
    if (templateInfo.value.type == 0) {
      flvPlayer = flvjs.createPlayer({
        type: 'flv',
        url: templateInfo.value.streamUrl
      })
      flvPlayer.attachMediaElement(videoElement)
      flvPlayer.load()
      flvPlayer.play()
    } else {
      // 录播
      videoElement.src = templateInfo.value.videoUrl
      // 获取视频时长并更新时间轴总时长
      fetchDuration(videoElement.src, 'video').then(() => {
        initTimelineRuler()
      })
    }
  }
}

3. 添加音频轨道,并可拖拽移动,且鼠标抬起时输出当前位置

javascript 复制代码
const startDrag = (el: MouseEvent, item: any, index: number) => {
  console.log(item.width)
  function checkWidth(width) {
    const widthValue = parseFloat(width)
    if (isNaN(widthValue)) {
      console.error('Invalid width value')
      return false
    }
    return widthValue <= 100
  }

  if (!checkWidth(item.width)) {
    return
  }
  // 判断事件源是否是按钮或按钮的子元素(如图标),若是则直接返回
  const target = el.target as HTMLElement
  if (
    target.tagName === 'BUTTON' ||
    target.closest('.track-item-actions') || // 按钮所在的容器
    target.classList.contains('el-icon') // 图标元素
  ) {
    return // 不执行拖拽逻辑
  }

  const element = document.getElementById(`mouseDiv-${index + 1}`)
  if (!element) return

  // 声明拖拽相关变量(使用let保证每次拖拽都是新的变量,避免作用域污染)
  let isDragging = false
  let startX = 0 // 鼠标按下时的X坐标
  let startLeftPx = 0 // 元素按下时的左侧像素位置
  let parentWidthPx = 0 // 父容器的宽度(像素)
  let itemWidthPx = 0 // 音频元素的宽度(像素)
  let animationFrameId: number | null = null
  let currentSecond = 0 // 当前拖拽秒数
  let newLeftPercent = 0 // 新的左侧位置百分比
  // 获取时间轴总时长(视频时长或默认60秒)
  const timelineDuration = durationVideo.value || 60

  // 1. 缓存父容器宽度(像素):拖拽开始时就获取,避免过程中变化
  const parentElement = element.parentElement
  if (!parentElement) return
  parentWidthPx = parentElement.offsetWidth
  // 2. 缓存音频元素的宽度(像素):实时获取,避免百分比精度问题
  itemWidthPx = element.offsetWidth
  // 3. 缓存元素按下时的左侧像素位置(使用offsetLeft,更稳定)
  startLeftPx = element.offsetLeft
  // 4. 鼠标按下时的X坐标
  startX = el.clientX

  // 禁用文本选择,优化拖拽体验
  document.body.style.userSelect = 'none'
  document.body.style.cursor = 'grabbing'

  // 鼠标移动处理函数
  const handleMouseMove = (e: MouseEvent) => {
    if (!isDragging || parentWidthPx === 0) return

    // 取消之前的动画帧,避免累积
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId)
    }

    // 使用requestAnimationFrame优化性能
    animationFrameId = requestAnimationFrame(() => {
      // 计算鼠标移动的像素偏移量
      const deltaX = e.clientX - startX

      // 新的左侧像素位置 = 初始左侧位置 + 鼠标偏移量
      let newLeftPx = startLeftPx + deltaX

      // 左边界:不能小于0
      newLeftPx = Math.max(0, newLeftPx)
      // 右边界:不能大于父容器宽度 - 元素宽度(避免元素超出右侧)
      newLeftPx = Math.min(newLeftPx, parentWidthPx - itemWidthPx)
      // 保留整数像素,避免亚像素渲染问题
      newLeftPx = Math.round(newLeftPx)

      // 转换为百分比,更新元素位置
      newLeftPercent = Number(((newLeftPx / parentWidthPx) * 100).toFixed(2))
      element.style.left = `${newLeftPercent}%`

      // 计算当前秒数
      currentSecond = Number(((newLeftPercent / 100) * timelineDuration).toFixed(1))

      // 更新数据中的开始时间
      item.startTime = currentSecond
    })
  }

  // 鼠标抬起处理函数
  const handleMouseUp = () => {
    console.log(index, `当前拖拽位置:${currentSecond} 秒(left:${newLeftPercent}%)`)
    isDragging = false

    // 恢复默认样式
    document.body.style.userSelect = ''
    document.body.style.cursor = ''

    // 取消最后的动画帧
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId)
    }

    // 移除事件监听(关键:避免多次拖拽时事件叠加)
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleMouseUp)
  }

  // 初始化拖拽状态(必须在变量缓存后)
  isDragging = true

  // 阻止默认行为和冒泡,避免事件冲突
  el.preventDefault()
  el.stopPropagation()

  // 先移除可能存在的旧监听,再添加新监听
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  document.addEventListener('mousemove', handleMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
}
const loading_add = ref(false)
// 获取音频时长(秒)
async function getAudioDuration(url) {
  return new Promise((resolve, reject) => {
    const audio = new Audio(url)

    audio.addEventListener('loadedmetadata', () => {
      console.log(audio.duration)
      // 返回四舍五入后的秒数
      resolve(Math.round(audio.duration))
    })

    audio.addEventListener('error', (e) => {
      reject(new Error('Failed to load audio file'))
    })
  })
}
// 初始化音频可视化条
const initAudio = async () => {
  loading_add.value = true
  const divID = 'audioTrack1'
  const exampleAudioItem = {
    id: 1001,
    divID: divID,
    type: 'audio',
    name: '背景音乐.mp3',
    startTime: 5,
    durationVideo: 20,
    volume: 70,
    audioFileUrl:
      'https://www.dkifly.com/ifly-api/admin-api/infra/file/29/get/20251216/24s_1765852490272.mp3'
  }
  try {
    durationAudio.value = await getAudioDuration(exampleAudioItem.audioFileUrl)
    exampleAudioItem.width = `${(durationAudio.value / durationVideo.value) * 100}%`
    console.log(exampleAudioItem.width)

    audioItems.value.push(exampleAudioItem)
    loading_add.value = false
    console.log(`音频时长: ${durationAudio.value} 秒`)
  } catch (error) {
    console.error('获取音频时长失败:', error.message)
  }
}

4. 总代码

javascript 复制代码
<template>
  <!-- 模板子集 -->
  <ContentWrap class="content">
    <div
      style="
        display: flex;
        align-items: center;
        text-align: center;
        width: 100%;
        justify-content: space-between;
        margin-bottom: 20px;
      "
    >
      <h2>
        <el-button @click.stop="handleCancel" plain type="primary" text>
          <Icon icon="ep:arrow-left" />
        </el-button>
      </h2>
      <h2>{{ mixedTitle }}</h2>
      <h2></h2>
    </div>
    <!-- 内容 -->
    <div class="main">
      <div class="main-content">
        <h3>视频流</h3>
        <div style="height: 1px; background-color: #dcdfe6; margin: 15px 0"></div>
        <div class="content-child">
          <div class="video-container">
            <video id="player" controls preload="auto" muted autoplay></video>
            <div class="sync-indicator">
              <div style="display: flex; align-items: center">
                <span>当前位置:已过航点1</span>
              </div>
              <div style="display: flex; align-items: center">
                <div class="sync-dot sync-good"></div>
                <span>推流信号</span>
              </div>
            </div>
          </div>
          <div class="control-bottom" v-if="1 == 0">
            <div class="info"></div>
            <div class="controls">
              <button class="btn btn-primary" id="startBtn">
                <Icon icon="ep:video-play" :size="20" class="mr-5px" />
                开始
              </button>
              <button class="btn btn-warning" id="stopBtn">
                <Icon icon="ep:video-pause" :size="20" class="mr-5px" /> 停止
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="main-content" style="margin: 60px 0 20px 0; background-color: rgba(42, 66, 100, 0)">
      <h3>添加素材</h3>
      <div style="height: 1px; background-color: #dcdfe6; margin: 15px 0"></div>

      <div class="timeline-header">
        <div class="timeline-info">
          <div
            >总时长: <span id="totalDuration">{{ durationVideo }}</span
            >秒</div
          >
        </div>
        <div class="timeline-controls">
          <el-button plain type="primary" @click="handleAdd" :loading="loading_add">
            <Icon class="mr-5px" icon="ep:plus" />
            新增
          </el-button>
        </div>
      </div>
      <!-- 时间标尺将通过JS生成 -->
      <div class="timeline-ruler" id="timelineRuler"> </div>
      <!-- 音频轨道 -->
      <div class="timeline-track" v-for="(item, index) in audioItems" :key="index">
        <div class="track-header">
          <div class="track-title">
            <span>音频轨道 {{ index + 1 }}</span>
          </div>
          <div class="track-controls">
            <button class="btn btn-icon btn-primary">
              <i class="fas fa-plus"></i>
            </button>
          </div>
        </div>
        <div class="track-items" :id="item.divID">
          <div class="current-time-marker" id="currentTimeMarker" style="left: 0%"></div>
          <div
            class="track-item track-item-audio"
            :id="`mouseDiv-${index + 1}`"
            :style="{ width: `${item.width}` }"
            @mousedown="startDrag($event, item, index)"
          >
            <div class="track-item-content">{{ item.name }}</div>
            <div class="track-item-handle track-item-handle-left"></div>
            <div class="track-item-handle track-item-handle-right"></div>
            <div class="track-item-actions">
              <el-button
                type="primary"
                size="small"
                circle
                @click="handleEdit($event, item)"
                @mousedown.stop.prevent
              >
                <Icon icon="ep:edit" />
              </el-button>
              <el-button
                type="danger"
                size="small"
                circle
                @click="handleDelete($event, item)"
                @mousedown.stop.prevent
              >
                <Icon icon="ep:delete" />
              </el-button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- 弹窗 -->
    <Dialog v-model="dialogVisible" :title="dialogTitle">
      <el-form
        ref="formRef"
        v-loading="formLoading"
        :model="formData"
        :rules="formRules"
        label-width="90px"
      >
        <el-form-item label="音频文件" prop="audioFileUrl">
          <UploadFile
            v-model="formData.audioFileUrl"
            :file-type="['mp3']"
            :limit="1"
            :file-size="10"
            class="min-w-80px"
          />
        </el-form-item>
        <el-form-item label="文本文件" prop="textFileUrl">
          <UploadFile
            v-model="formData.textFileUrl"
            :file-type="[]"
            :limit="1"
            :file-size="0"
            class="min-w-80px"
          />
        </el-form-item>
        <el-form-item label="音量" prop="audioVolume">
          <el-row :gutter="24" style="width: 100%">
            <el-col :span="21">
              <el-slider
                v-model="formData.audioVolume"
                :max="100"
                :min="0"
                :show-input-controls="true"
                :format-tooltip="formatTooltip"
                style="width: 100%"
              />
            </el-col>
            <el-col :span="3" style="display: flex; justify-content: center">
              {{ formData.audioVolume }} %
            </el-col>
          </el-row>
        </el-form-item>
        <el-form-item label="备注">
          <el-input
            v-model="formData.remark"
            placeholder="请输入备注"
            type="textarea"
            :autosize="{ minRows: 3, maxRows: 3 }"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button :disabled="formLoading" type="primary" @click="submitForm">保 存</el-button>
        <el-button @click="dialogVisible = false">关 闭</el-button>
      </template>
    </Dialog>
  </ContentWrap>
</template>

<script lang="ts" setup>
import * as DirectApi from '@/api/direct/direct'
import { FormRules } from 'element-plus'
import flvjs from 'flv.js'

const emit = defineEmits(['success', 'close']) // 定义事件,用于操作成功后的回调

const handleCancel = () => {
  emit('close', 'origin')
}

const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 是否显示弹窗

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  currentId: {
    type: Number,
    default: 0
  }
})

const Tableloading = ref(false) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([]) // 列表的数据
const dialogTitle = ref('') // 表单的类型:create - 新增;update - 修改

const queryParams = ref({
  pageNo: 1,
  pageSize: 10
}) // 查询参数
const getList = async () => {
  Tableloading.value = true
  try {
    const data = await DirectApi.getMixDetailPage(queryParams.value)
    list.value = data.list
    total.value = data.total
  } finally {
    Tableloading.value = false
  }
}

const mixedTitle = ref(props.title)

/**
 * 表单模块
 */
const formLoading = ref(false)
const resetFrom = () => {
  return {
    templateId: undefined,
    routeId: undefined,
    routeDrawId: undefined,
    pointName: '',
    lng: 138.222321,
    lat: 138.222321,
    alt: 138.222321,
    audioFileUrl: '',
    textFileUrl: '',
    audioText: '文本文字测试',
    audioVolume: 80,
    remark: ''
  }
}
const formData = ref(resetFrom())
const formRules = reactive<FormRules>({
  routeDrawId: [{ required: true, message: '请选择航点', trigger: 'blur' }],
  pointName: [{ required: true, message: '请输入航点名称', trigger: 'blur' }],
  audioFileUrl: [{ required: true, message: '请上传音频', trigger: 'blur' }],
  textFileUrl: [{ required: true, message: '请上传文本', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
const submitForm = async () => {
  // 校验表单
  if (!formRef) return
  const valid = await formRef.value.validate()
  if (!valid) return
  // 提交请求
  formData.value.id == undefined ? (dialogTitle.value = '新增') : (dialogTitle.value = '修改')
  let tempData = {}
  try {
    // 提交请求
    if (dialogTitle.value === '新增') {
      tempData = {
        ...formData.value,
        templateId: templateInfo.value?.id,
        routeId: templateInfo.value?.routeId,
        routeDrawId: formData.value.routeDrawId
      }
      await DirectApi.createMixDetail(tempData)
      message.success('保存成功')
    } else {
      tempData = formData.value
      await DirectApi.updateMixDetail(tempData)
      message.success(dialogTitle.value + '成功')
    }
    // 发送操作成功的事件
    dialogVisible.value = false
  } finally {
    formLoading.value = false
    handlerouteDrawChange(formData.value.routeDrawId)
  }
}

// 初始化音量
const formatTooltip = (val: number) => {
  return val + '%'
}

const routeDrawList = ref([])
const templateInfo = ref([])
const getCurrentDetail = async () => {
  const id = props.currentId
  if (id) {
    formLoading.value = true
    try {
      const res = await DirectApi.getDirectDetail(id)
      templateInfo.value = res
      handlePlay()
      routeDrawList.value = res.routeInfo?.routeDrawList
      handlerouteDrawChange(formData.value.routeDrawId)
    } finally {
      formLoading.value = false
    }
  }
}

const handlerouteDrawChange = async (val: number | string) => {
  // 查询当前航点的音频文件和文本文件
  const tempData = {
    templateId: props.currentId,
    routeId: templateInfo.value?.routeId,
    routeDrawId: val
  }
  await DirectApi.getMixRouteDetail(tempData).then((res) => {
    if (res.list?.length > 0) {
      formData.value = res.list?.[0]
    } else {
      formData.value = resetFrom()
      const currentRoute = routeDrawList.value.find((item: any) => item.id == val)
      if (currentRoute) {
        formData.value.pointName = currentRoute.pointName
        formData.value.lng = currentRoute.lng
        formData.value.lat = currentRoute.lat
        formData.value.alt = currentRoute.alt
      }
    }
  })
}

/**
 * 视频播放
 */
let flvPlayer: any = null
const handlePlay = () => {
  if (flvjs.isSupported()) {
    const videoElement = document.getElementById('player') as HTMLVideoElement
    // 直播
    if (templateInfo.value.type == 0) {
      flvPlayer = flvjs.createPlayer({
        type: 'flv',
        url: templateInfo.value.streamUrl
      })
      flvPlayer.attachMediaElement(videoElement)
      flvPlayer.load()
      flvPlayer.play()
    } else {
      // 录播
      videoElement.src = templateInfo.value.videoUrl
      // 获取视频时长并更新时间轴总时长
      fetchDuration(videoElement.src, 'video').then(() => {
        initTimelineRuler()
      })
    }
  }
}

onMounted(async () => {
  formData.value = resetFrom()
  await getCurrentDetail()
  initTimelineRuler()
})

onBeforeUnmount(() => {
  // 销毁播放器
  if (flvPlayer) {
    flvPlayer.destroy()
  }
})

// 获取视频时长
const durationVideo = ref<number | null>(null)
const durationAudio = ref<number | null>(null)
const errorMsg = ref('')

/**
 * 获取远程音视频的时长(单位:秒)
 * @param {string} videoUrl - 视频链接
 * @returns {Promise<number>} 视频时长(秒)
 */
const getVideoDurationInSeconds = async (videoUrl: string): Promise<number> => {
  return new Promise((resolve, reject) => {
    // 修复:创建新的video元素,避免影响页面中的播放器
    const video = document.createElement('video')
    video.crossOrigin = 'anonymous'
    video.onloadedmetadata = () => {
      const duration = Number(video.duration?.toFixed(0))
      // 释放资源
      video.removeAttribute('src')
      video.load()
      URL.revokeObjectURL(video.src)
      resolve(duration)
    }
    video.onerror = () => {
      reject(new Error('视频加载失败'))
      video.removeAttribute('src')
      video.load()
      URL.revokeObjectURL(video.src)
    }
    setTimeout(() => {
      reject(new Error('获取视频时长超时'))
    }, 10000)
    video.src = videoUrl
    video.load()
  })
}

const getMediaDurationInSeconds = async (mediaUrl: string): Promise<number> => {
  return new Promise((resolve, reject) => {
    const media = document.createElement('audio')
    media.crossOrigin = 'anonymous'

    media.onloadedmetadata = () => {
      if (isNaN(media.duration)) {
        reject(new Error('媒体文件元数据缺失,无法获取时长'))
        return
      }
      const duration = Math.round(Number(media.duration.toFixed(2)))

      media.removeAttribute('src')
      media.load()
      URL.revokeObjectURL(media.src)
      resolve(duration)
    }

    media.onerror = () => {
      reject(new Error('加载失败:可能是跨域、链接无效或格式不支持'))
      media.removeAttribute('src')
      media.load()
      URL.revokeObjectURL(media.src)
    }

    setTimeout(() => {
      reject(new Error('获取时长超时,请检查链接'))
    }, 10000)

    media.src = mediaUrl
    media.load()
  })
}

// 触发获取时长
const fetchDuration = async (Url: string, type: string) => {
  if (type == 'video') {
    try {
      durationVideo.value = await getVideoDurationInSeconds(Url)
      errorMsg.value = ''
    } catch (err) {
      durationVideo.value = 60 // 失败时使用默认60秒
      errorMsg.value = (err as Error).message
    }
  }
}

/**
 * 初始化时间标尺
 */
const audioItems = ref([]) // 音频数组
function initTimelineRuler() {
  const timelineRuler = document.getElementById('timelineRuler')
  if (!timelineRuler) return
  timelineRuler.innerHTML = ''

  // 获取视频时长(确保为有效正整数,默认60秒)
  const timelineDuration = Math.max(Number(durationVideo.value) || 60, 1) // 至少1秒
  // 最小刻度数量(视觉下限,避免刻度太少)
  const MIN_MARKS = 8
  // 最大刻度数量(视觉上限,避免刻度太密)
  const MAX_MARKS = 20
  // 新增:间隔的最小限制(1秒,且为整数)
  const MIN_INTERVAL = 1

  /**
   * 核心算法:计算最优刻度间隔(保证是整数,且≥1秒)
   * 思路:
   * 1. 先计算初始间隔(总时长 / 最大刻度数,保证不超最大数量),取整后至少为1秒
   * 2. 把间隔向上取整为「1、2、5、10、20、50、100...」这类易读的整数(人类视觉友好)
   * 3. 最终保证刻度覆盖0到总时长,且数量在MIN~MAX之间
   */
  function getOptimalInterval(total, minMarks, maxMarks, minInterval) {
    // 初始间隔:按最大刻度数平分总时长,取整后至少为最小间隔(1秒)
    let initialInterval = Math.max(Math.ceil(total / maxMarks), minInterval)
    // 易读的间隔基数(人类习惯的整数刻度单位,避免出现3、7这类不规则数)
    const baseIntervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]

    // 找到比初始间隔大的最小基数(比如初始间隔是8,取10;初始间隔是3,取5)
    let optimalInterval = baseIntervals.find((interval) => interval >= initialInterval)
    // 若初始间隔超过所有基数,取最大基数的整数倍(比如总时长1000,初始间隔50,取50)
    if (!optimalInterval) {
      const maxBase = baseIntervals[baseIntervals.length - 1]
      optimalInterval = Math.ceil(initialInterval / maxBase) * maxBase
    }

    // 校验刻度数量:如果按当前间隔计算的刻度数太少,缩小间隔(保证至少minMarks个,且不小于最小间隔)
    let markCount = Math.ceil(total / optimalInterval) + 1 // +1 包含0
    while (markCount < minMarks) {
      // 找到当前间隔的前一个更小的基数(比如当前是10,前一个是5)
      const currentIndex = baseIntervals.indexOf(optimalInterval)
      if (currentIndex > 0) {
        optimalInterval = baseIntervals[currentIndex - 1]
        // 确保缩小后的间隔不小于最小间隔(1秒)
        if (optimalInterval < minInterval) {
          optimalInterval = minInterval
          break
        }
      } else {
        // 已经是最小基数(1秒),无法再缩小,直接退出
        break
      }
      markCount = Math.ceil(total / optimalInterval) + 1
    }

    // 最终确保间隔是整数且≥最小间隔
    return Math.max(Math.round(optimalInterval), minInterval)
  }

  // 获取最优间隔(整数,≥1秒)
  const interval = getOptimalInterval(timelineDuration, MIN_MARKS, MAX_MARKS, MIN_INTERVAL)
  // 计算刻度的起始(0)和结束(总时长),以及所有中间刻度
  let time = 0
  // 循环生成刻度,直到超过总时长(最后一个刻度强制为总时长)
  while (time <= timelineDuration) {
    // 处理最后一个刻度:如果当前时间+间隔超过总时长,直接用总时长作为最后一个刻度
    let currentTime = time
    // 避免出现超过总时长的刻度,且保证currentTime是整数
    if (currentTime > timelineDuration) {
      currentTime = timelineDuration
    }
    // 强制转为整数(防止精度问题出现小数)
    currentTime = Math.floor(currentTime)

    // 创建刻度元素
    const mark = document.createElement('div')
    mark.className = 'ruler-mark'
    mark.style.left = `${(currentTime / timelineDuration) * 100}%`
    mark.setAttribute('data-time', `${currentTime}s`)
    timelineRuler.appendChild(mark)

    // 最后一个刻度如果是总时长,停止循环
    if (currentTime === timelineDuration) {
      break
    }

    // 增加间隔(确保是整数,避免小数)
    time += interval
    // 处理极端情况:如果time累加后变成小数(如精度问题),强制取整
    time = Math.floor(time)
  }

  // 兜底:如果上述循环未生成最后一个刻度(极端精度问题),手动添加总时长刻度
  const lastMark = timelineRuler.lastChild
  const lastMarkTime = lastMark ? Number(lastMark.getAttribute('data-time').replace('s', '')) : -1
  if (lastMarkTime !== timelineDuration) {
    const mark = document.createElement('div')
    mark.className = 'ruler-mark'
    mark.style.left = '100%'
    mark.setAttribute('data-time', `${timelineDuration}s`)
    timelineRuler.appendChild(mark)
  }
}

const startDrag = (el: MouseEvent, item: any, index: number) => {
  console.log(item.width)
  function checkWidth(width) {
    const widthValue = parseFloat(width)
    if (isNaN(widthValue)) {
      console.error('Invalid width value')
      return false
    }
    return widthValue <= 100
  }

  if (!checkWidth(item.width)) {
    return
  }
  // 判断事件源是否是按钮或按钮的子元素(如图标),若是则直接返回
  const target = el.target as HTMLElement
  if (
    target.tagName === 'BUTTON' ||
    target.closest('.track-item-actions') || // 按钮所在的容器
    target.classList.contains('el-icon') // 图标元素
  ) {
    return // 不执行拖拽逻辑
  }

  const element = document.getElementById(`mouseDiv-${index + 1}`)
  if (!element) return

  // 声明拖拽相关变量(使用let保证每次拖拽都是新的变量,避免作用域污染)
  let isDragging = false
  let startX = 0 // 鼠标按下时的X坐标
  let startLeftPx = 0 // 元素按下时的左侧像素位置
  let parentWidthPx = 0 // 父容器的宽度(像素)
  let itemWidthPx = 0 // 音频元素的宽度(像素)
  let animationFrameId: number | null = null
  let currentSecond = 0 // 当前拖拽秒数
  let newLeftPercent = 0 // 新的左侧位置百分比
  // 获取时间轴总时长(视频时长或默认60秒)
  const timelineDuration = durationVideo.value || 60

  // 1. 缓存父容器宽度(像素):拖拽开始时就获取,避免过程中变化
  const parentElement = element.parentElement
  if (!parentElement) return
  parentWidthPx = parentElement.offsetWidth
  // 2. 缓存音频元素的宽度(像素):实时获取,避免百分比精度问题
  itemWidthPx = element.offsetWidth
  // 3. 缓存元素按下时的左侧像素位置(使用offsetLeft,更稳定)
  startLeftPx = element.offsetLeft
  // 4. 鼠标按下时的X坐标
  startX = el.clientX

  // 禁用文本选择,优化拖拽体验
  document.body.style.userSelect = 'none'
  document.body.style.cursor = 'grabbing'

  // 鼠标移动处理函数
  const handleMouseMove = (e: MouseEvent) => {
    if (!isDragging || parentWidthPx === 0) return

    // 取消之前的动画帧,避免累积
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId)
    }

    // 使用requestAnimationFrame优化性能
    animationFrameId = requestAnimationFrame(() => {
      // 计算鼠标移动的像素偏移量
      const deltaX = e.clientX - startX

      // 新的左侧像素位置 = 初始左侧位置 + 鼠标偏移量
      let newLeftPx = startLeftPx + deltaX

      // 左边界:不能小于0
      newLeftPx = Math.max(0, newLeftPx)
      // 右边界:不能大于父容器宽度 - 元素宽度(避免元素超出右侧)
      newLeftPx = Math.min(newLeftPx, parentWidthPx - itemWidthPx)
      // 保留整数像素,避免亚像素渲染问题
      newLeftPx = Math.round(newLeftPx)

      // 转换为百分比,更新元素位置
      newLeftPercent = Number(((newLeftPx / parentWidthPx) * 100).toFixed(2))
      element.style.left = `${newLeftPercent}%`

      // 计算当前秒数
      currentSecond = Number(((newLeftPercent / 100) * timelineDuration).toFixed(1))

      // 更新数据中的开始时间
      item.startTime = currentSecond
    })
  }

  // 鼠标抬起处理函数
  const handleMouseUp = () => {
    console.log(index, `当前拖拽位置:${currentSecond} 秒(left:${newLeftPercent}%)`)
    isDragging = false

    // 恢复默认样式
    document.body.style.userSelect = ''
    document.body.style.cursor = ''

    // 取消最后的动画帧
    if (animationFrameId) {
      cancelAnimationFrame(animationFrameId)
    }

    // 移除事件监听(关键:避免多次拖拽时事件叠加)
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleMouseUp)
  }

  // 初始化拖拽状态(必须在变量缓存后)
  isDragging = true

  // 阻止默认行为和冒泡,避免事件冲突
  el.preventDefault()
  el.stopPropagation()

  // 先移除可能存在的旧监听,再添加新监听
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  document.addEventListener('mousemove', handleMouseMove)
  document.addEventListener('mouseup', handleMouseUp)
}
const loading_add = ref(false)
// 获取音频时长(秒)
async function getAudioDuration(url) {
  return new Promise((resolve, reject) => {
    const audio = new Audio(url)

    audio.addEventListener('loadedmetadata', () => {
      console.log(audio.duration)
      // 返回四舍五入后的秒数
      resolve(Math.round(audio.duration))
    })

    audio.addEventListener('error', (e) => {
      reject(new Error('Failed to load audio file'))
    })
  })
}
// 初始化音频可视化条
const initAudio = async () => {
  loading_add.value = true
  const divID = 'audioTrack1'
  const exampleAudioItem = {
    id: 1001,
    divID: divID,
    type: 'audio',
    name: '背景音乐.mp3',
    startTime: 5,
    durationVideo: 20,
    volume: 70,
    audioFileUrl:
      'https://www.dkifly.com/ifly-api/admin-api/infra/file/29/get/20251216/24s_1765852490272.mp3'
  }
  try {
    durationAudio.value = await getAudioDuration(exampleAudioItem.audioFileUrl)
    exampleAudioItem.width = `${(durationAudio.value / durationVideo.value) * 100}%`
    console.log(exampleAudioItem.width)

    audioItems.value.push(exampleAudioItem)
    loading_add.value = false
    console.log(`音频时长: ${durationAudio.value} 秒`)
  } catch (error) {
    console.error('获取音频时长失败:', error.message)
  }
}
const handleAdd = () => {
  // dialogVisible.value = true
  // dialogTitle.value = '新增'
  initAudio()
}

// 编辑方法
const handleEdit = function (el: MouseEvent, item: any) {
  console.log('编辑', item)
  // 这里可以添加编辑逻辑,比如打开弹窗赋值
  // formData.value = { ...item }
  // dialogVisible.value = true
  // dialogTitle.value = '编辑'
}

// 删除方法
const handleDelete = async (el: MouseEvent, item: any) => {
  try {
    // 删除的二次确认
    await message.delConfirm()
    // 发起删除
    await DirectApi.deleteMixDetail(item.id || formData.value.id)
    message.success('删除成功')
    // 刷新列表
    handlerouteDrawChange(formData.value.routeDrawId)
  } catch (err) {
    console.log('删除取消', err)
  }
}
</script>

<style scoped>
.content {
  padding: 20px;
}
.content-child {
  padding: 15px 20px;
}
.main {
  width: 100%;
  display: flex;
  justify-content: space-between;
  --primary: #4361ee;
  --secondary: #3f37c9;
  --success: #95f204;
  --warning: #f72585;
  --dark: #1d3557;
  --light: #f8f9fa;
  --gray: #adb5bd;
}
.main-content {
  width: 100%;
  border-radius: 15px;
  padding: 20px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(10px);
}
.video-container {
  position: relative;
  width: 100%;
  height: 300px;
  background: #000;
  border-radius: 10px;
  overflow: hidden;
  margin-bottom: 20px;
}

video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.sync-indicator {
  position: absolute;
  top: 15px;
  width: 100%;
  background: rgba(0, 0, 0, 0);
  padding: 8px 15px;
  border-radius: 20px;
  font-size: 0.9rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: var(--light);
}

.sync-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  margin-right: 8px;
}

.sync-good {
  background: var(--success);
  box-shadow: 0 0 10px var(--success);
}

.sync-warning {
  background: var(--warning);
  box-shadow: 0 0 10px var(--warning);
}
.control-bottom {
  display: flex;
  justify-content: space-between;
}
.info {
  font-size: 0.9rem;
}
.controls {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 15px;
  margin-bottom: 20px;
}

.btn {
  padding: 12px 15px;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.3s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

.btn i {
  margin-right: 8px;
}

.btn-primary {
  background: var(--primary);
  color: white;
}

.btn-primary:hover {
  background: var(--secondary);
  transform: translateY(-2px);
}

.btn-success {
  background: #4cc9f0;
  color: var(--dark);
}

.btn-success:hover {
  background: #3aa8d0;
  transform: translateY(-2px);
}

.btn-warning {
  background: var(--warning);
  color: white;
}

.btn-warning:hover {
  background: #ecf5ff;
  transform: translateY(-2px);
}

.btn:disabled {
  background: var(--gray);
  cursor: not-allowed;
  transform: none;
}
/* 时间轴样式 */
.timeline-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 25px;
}
</style>

<style>
/* 时间轴样式 */
.timeline-ruler {
  display: flex;
  height: 20px;
  background: rgba(204, 204, 204, 0.05);
  border-radius: 3px;
  margin-bottom: 5px;
  position: relative;
}
.ruler-mark {
  position: absolute;
  height: 100%;
  width: 1px;
  background: rgba(255, 255, 255, 0.2);
  text-align: center;
  font-size: 0.7rem;
  color: var(--gray);
}

.ruler-mark::after {
  content: attr(data-time);
  position: absolute;
  top: -15px;
  left: -10px;
  width: 20px;
}

.subtitle {
  font-size: 1.1rem;
  color: var(--gray);
}

.dashboard {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-bottom: 30px;
}

@media (max-width: 1024px) {
  .dashboard {
    grid-template-columns: 1fr;
  }
}

.panel {
  background: rgba(29, 53, 87, 0.7);
  border-radius: 15px;
  padding: 20px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
  backdrop-filter: blur(10px);
}

.panel-title {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.panel-title i {
  margin-right: 10px;
  color: var(--success);
}

video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.sync-indicator {
  position: absolute;
  top: 15px;
  right: 15px;
  background: rgba(0, 0, 0, 0.6);
  padding: 8px 15px;
  border-radius: 20px;
  font-size: 0.9rem;
  display: flex;
  align-items: center;
}

.sync-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  margin-right: 8px;
}

.sync-good {
  background: var(--success);
  box-shadow: 0 0 10px var(--success);
}

.sync-warning {
  background: var(--warning);
  box-shadow: 0 0 10px var(--warning);
}

.controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 15px;
  margin-bottom: 20px;
}

.btn {
  padding: 12px 15px;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.3s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

.btn i {
  margin-right: 8px;
}

.btn-primary {
  background: var(--primary);
  color: white;
}

.btn-primary:hover {
  background: var(--secondary);
  transform: translateY(-2px);
}

.btn-success {
  background: var(--success);
  color: var(--dark);
}

.btn-success:hover {
  background: #3aa8d0;
  transform: translateY(-2px);
}

.btn-warning {
  background: var(--warning);
  color: white;
}

.btn-warning:hover {
  background: #d41c6c;
  transform: translateY(-2px);
}

.btn:disabled {
  background: var(--gray);
  cursor: not-allowed;
  transform: none;
}

.stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 15px;
  margin-top: 20px;
}

.stat-card {
  background: rgba(255, 255, 255, 0.1);
  padding: 15px;
  border-radius: 10px;
  text-align: center;
}

.stat-value {
  font-size: 1.8rem;
  font-weight: 700;
  margin: 10px 0;
}

.stat-label {
  font-size: 0.9rem;
  color: var(--gray);
}

.settings {
  margin-top: 20px;
}

.setting-group {
  margin-bottom: 20px;
}

.setting-label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
}

.slider-container {
  display: flex;
  align-items: center;
}

.slider {
  flex: 1;
  height: 8px;
  -webkit-appearance: none;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 4px;
  outline: none;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--primary);
  cursor: pointer;
}

.slider-value {
  width: 50px;
  text-align: right;
  margin-left: 10px;
}

.visualizer {
  height: 120px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 10px;
  margin-top: 20px;
  position: relative;
  overflow: hidden;
}

.sync-timeline {
  height: 10px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 5px;
  margin-top: 20px;
  position: relative;
}

.sync-marker {
  position: absolute;
  top: 0;
  width: 4px;
  height: 100%;
  background: var(--success);
  transform: translateX(-50%);
}

.video-marker {
  position: absolute;
  top: 0;
  width: 4px;
  height: 100%;
  background: var(--primary);
  transform: translateX(-50%);
}

.audio-marker {
  position: absolute;
  top: 0;
  width: 4px;
  height: 100%;
  background: var(--warning);
  transform: translateX(-50%);
}

.timeline-labels {
  display: flex;
  justify-content: space-between;
  margin-top: 5px;
  font-size: 0.8rem;
  color: var(--gray);
}

.log-container {
  margin-top: 20px;
  max-height: 150px;
  overflow-y: auto;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 8px;
  padding: 10px;
  font-size: 0.85rem;
}

.log-entry {
  padding: 5px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.log-time {
  color: var(--success);
  margin-right: 10px;
}

.log-info {
  color: var(--gray);
}

.log-warning {
  color: var(--warning);
}

/* 多轨道时间轴样式 */
.timeline-container {
  margin-top: 30px;
}

.timeline-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}

.timeline-controls {
  display: flex;
  gap: 10px;
}

.timeline-track {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 8px;
  margin-bottom: 15px;
}

.track-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}

.track-title {
  display: flex;
  align-items: center;
  font-weight: 600;
}

.track-title i {
  margin-right: 10px;
  color: var(--success);
}

.track-items {
  display: flex;
  align-items: center;
  height: 60px;
  background: rgba(0, 0, 0, 0.2);
  border-radius: 5px;
  position: relative;
  overflow-x: auto;
  padding: 5px;
  overflow-y: hidden;
}

.timeline-ruler {
  display: flex;
  height: 20px;
  background: rgba(204, 204, 204, 1);
  border-radius: 3px;
  margin-bottom: 5px;
  position: relative;
}

.ruler-mark {
  position: absolute;
  height: 100%;
  width: 1px;
  background: rgba(255, 255, 255, 1);
  text-align: center;
  font-size: 0.7rem;
  color: var(--gray);
}

.ruler-mark::after {
  content: attr(data-time);
  position: absolute;
  top: -15px;
  left: -10px;
  width: 20px;
}

.track-item {
  position: absolute;
  height: 50px;
  border-radius: 5px;
  display: flex;
  align-items: center;
  padding: 0 10px;
  cursor: move;
  overflow: hidden;
  transition: all 0.2s ease;
}

.track-item:hover {
  filter: brightness(1.1);
}

.track-item-audio {
  background: rgba(153, 153, 153, 0.6);
}

.track-item-tts {
  background: linear-gradient(135deg, #f72585, #b5179e);
}

.track-item-content {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 0.85rem;
}

.track-item-handle {
  position: absolute;
  top: 0;
  width: 10px;
  height: 100%;
  opacity: 0.7;
}

.track-item-handle-left {
  left: 0;
}

.track-item-handle-right {
  right: 0;
}

.track-item-actions {
  position: absolute;
  top: 5px;
  right: 5px;
  display: none;
  gap: 4px; /* 添加间距,按钮更美观 */
}

.track-item:hover .track-item-actions {
  display: flex;
}

.btn-icon {
  width: 20px;
  height: 20px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.5);
  border-radius: 3px;
  margin-left: 3px;
}

.btn-icon i {
  margin-right: 0;
  font-size: 0.7rem;
}

.current-time-marker {
  position: absolute;
  top: 0;
  width: 2px;
  height: 100%;
  background: var(--success);
  z-index: 10;
}

.current-time-marker::after {
  content: '';
  position: absolute;
  top: 0;
  left: -4px;
  width: 10px;
  height: 10px;
  background: var(--success);
  border-radius: 50%;
}

.mixing-controls {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
  margin-top: 20px;
}

@media (max-width: 768px) {
  .mixing-controls {
    grid-template-columns: 1fr;
  }
}

.mixing-section {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 10px;
  padding: 15px;
}

.mixing-section h3 {
  margin-bottom: 15px;
  display: flex;
  align-items: center;
}

.mixing-section h3 i {
  margin-right: 10px;
  color: var(--success);
}

.form-group {
  margin-bottom: 15px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-size: 0.9rem;
}

.form-control {
  width: 100%;
  padding: 10px;
  border-radius: 5px;
  border: none;
  background: rgba(255, 255, 255, 0.1);
  color: white;
}

textarea.form-control {
  min-height: 80px;
  resize: vertical;
}

.time-inputs {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}
</style>
相关推荐
大学生小郑2 小时前
亮度噪声和色度噪声
图像处理·音视频·视频
星海之恋9922 小时前
便宜又好用的移动 4G 蜂窝代理快来看看!
音视频
传说故事2 小时前
【论文自动阅读】视频生成模型的Inference-time物理对齐 with Latent World Model
人工智能·深度学习·音视频·视频生成
Bits to Atoms2 小时前
宇树G1语音助手完整开发指南(下)——从零构建智能知识库对话系统
人工智能·机器人·音视频·语音识别
行业探路者3 小时前
2026年热销榜单:富媒体展示二维码推荐,助力信息传递新风尚
大数据·音视频·二维码
大学生小郑4 小时前
影像测评知识分享
图像处理·音视频·视频
枫叶丹44 小时前
【Qt开发】Qt系统(十一)-> Qt 音频
c语言·开发语言·c++·qt·音视频
发哥来了4 小时前
主流AI视频生成商用方案选型评测:关键能力与成本效益分析
大数据·人工智能·音视频
sweetone4 小时前
Rogers(乐爵士)A75 Series 2 功放之再修
经验分享·音视频