“AI 正在生成视频分镜...” 这个 loading 状态比你想的复杂多了

JavaScript性能优化实战 10w+人浏览 469人参与

在这里插入图片描述

阅读大约需 12 分钟


loading 变量让小禾发了一场高烧

小禾以为自己已经掌握了状态管理的精髓。

毕竟,嵌套数据、持久化存储这些难题都被他一一拿下了。

直到产品经理说:“加个生成图片的按钮吧。”

小禾想:这还不简单?三分钟搞定:

// 看起来很简单对吧?
const loading = ref(false)

async function generateImage() {
  loading.value = true
  await api.generate()
  loading.value = false
}
<template>
  <button @click="generateImage" :disabled="loading">
    {{ loading ? '生成中...' : '生成图片' }}
  </button>
</template>

小禾心满意足地提交了代码。

然后,bug 们排起了长队。


Bug 1:用户手速太快

测试妹子报了第一个 bug:“我点了两下,出来了两张图。”

小禾一看日志:

[14:23:01] 开始生成图片
[14:23:01] 开始生成图片    ← 用户又点了一下
[14:23:05] 图片生成完成
[14:23:06] 图片生成完成    ← 这是第二张

问题:按钮虽然在 loading 时 disabled 了,但有时候用户的点击比状态更新更快。

小禾加了个防抖:

const loading = ref(false)

async function generateImage() {
  // 防止重复点击
  if (loading.value) {
    console.warn('已有任务在进行中')
    return
  }

  loading.value = true
  try {
    await api.generate()
  } finally {
    loading.value = false
  }
}

好了,第一个 bug 修完。


Bug 2:生成失败了,但按钮还是灰的

测试妹子又来了:“我断网试了下,按钮一直是灰的,点不了。”

小禾一看代码:

async function generateImage() {
  loading.value = true
  await api.generate()  // 报错了
  loading.value = false  // 这行没执行到!
}

经典的遗漏 try-finally。

async function generateImage() {
  loading.value = true
  try {
    await api.generate()
  } catch (error) {
    console.error('生成失败:', error)
    // 这里是不是要提示用户?
  } finally {
    loading.value = false  // 无论成功失败都执行
  }
}

等等,失败了应该告诉用户吧?

一个 loading 变量不够用了。


Bug 3:成功了还是失败了?用户分不清

产品经理说:“生成失败后,用户不知道发生了什么,以为在加载。”

小禾意识到,true/false 不足以描述所有状态:

用户看到的:
- 按钮可点击 → 可以开始生成
- 按钮灰色 → 正在生成
- 按钮可点击 → 生成完了(成功?失败?)

他需要更多的状态:

type GenerationStatus =
  | 'idle'       // 空闲,可以开始
  | 'generating' // 生成中
  | 'generated'  // 已生成(成功)
  | 'error'      // 出错了

const status = ref<GenerationStatus>('idle')
const errorMessage = ref<string>('')

现在 UI 可以根据状态显示不同内容:

<template>
  <div>
    <button @click="generate" :disabled="status === 'generating'">
      <template v-if="status === 'generating'">
        <LoadingSpinner /> 生成中...
      </template>
      <template v-else-if="status === 'error'">
        重新生成
      </template>
      <template v-else-if="status === 'generated'">
        重新生成
      </template>
      <template v-else>
        生成图片
      </template>
    </button>

    <div v-if="status === 'error'" class="error-tip">
      {{ errorMessage }}
    </div>
  </div>
</template>

终于,用户能分清发生了什么。


Bug 4:同时生成图片和配音,状态乱了

功能越来越多,现在不只是生成图片了:

  • 生成图片
  • 生成配音
  • 生成动画
  • 合成视频

产品经理说:“用户可以同时生成图片和配音,不用等。”

小禾的一个 loading 变量彻底崩溃了:

// ❌ 这样写,两个操作会互相干扰
const loading = ref(false)

async function generateImage() {
  loading.value = true
  await api.generateImage()
  loading.value = false  // 配音还在生成呢!
}

async function generateAudio() {
  loading.value = true
  await api.generateAudio()
  loading.value = false  // 图片还在生成呢!
}

解决方案:每种操作有自己的状态。

// stores/editor.ts
export const useEditorStore = defineStore('editor', () => {
  // 不同类型的生成操作,各自独立
  const generatingImage = ref(false)
  const generatingAnimation = ref(false)
  const generatingAudio = ref(false)
  const generatingVideo = ref(false)

  // 是否有任何生成在进行(用于全局 loading 遮罩)
  const isGenerating = computed(() =>
    generatingImage.value ||
    generatingAnimation.value ||
    generatingAudio.value ||
    generatingVideo.value
  )

  return {
    generatingImage,
    generatingAnimation,
    generatingAudio,
    generatingVideo,
    isGenerating
  }
})

现在可以愉快地并行生成了:

async function generateImageForShot() {
  generatingImage.value = true
  try {
    await api.generateImage()
  } finally {
    generatingImage.value = false
  }
}

async function generateAudioForShot() {
  generatingAudio.value = true
  try {
    await api.generateAudio()
  } finally {
    generatingAudio.value = false
  }
}

// 同时开始,互不干扰
await Promise.all([
  generateImageForShot(),
  generateAudioForShot()
])

Bug 5:每个分镜都有自己的状态

问题又升级了。

产品经理说:“每个分镜都能单独生成图片,而且要显示各自的进度。”

小禾画了张图:

场景 1
├── 分镜 1:已生成
├── 分镜 2:生成中... ← 显示 loading
├── 分镜 3:生成失败 ← 显示错误
└── 分镜 4:未生成

场景 2
├── 分镜 1:生成中... ← 也在生成
└── 分镜 2:未生成

一个全局的 generatingImage 不够用了。

每个分镜都需要自己的状态。

interface Shot {
  id: string
  prompt: string
  imageUrl?: string
  status: GenerationStatus  // 每个分镜有自己的状态
  errorMessage?: string
}

现在修改分镜时同时更新状态:

// stores/editor.ts
async function generateShotImage() {
  const shot = storyStore.currentShot
  if (!shot) return

  // 检查:这个分镜已经在生成中?
  if (shot.status === 'generating') {
    console.warn('该分镜已在生成中')
    return
  }

  try {
    // 1. 设置为生成中
    storyStore.updateShot(shot.sceneId, shot.id, {
      status: 'generating',
      errorMessage: undefined
    })

    // 2. 调用 API
    const result = await generateApi.generateShotImage({
      prompt: shot.prompt
    })

    // 3. 更新成功结果
    storyStore.updateShot(shot.sceneId, shot.id, {
      status: 'generated',
      imageUrl: result.data.imageUrl
    })

    return result.data

  } catch (error: any) {
    // 4. 处理错误
    console.error('生成失败:', error)
    storyStore.updateShot(shot.sceneId, shot.id, {
      status: 'error',
      errorMessage: error.message || '生成失败,请重试'
    })
    throw error
  }
}

UI 层根据每个分镜的状态渲染:

<template>
  <div class="shot-card" :class="statusClass">
    <!-- 图片区域 -->
    <div class="shot-image">
      <img v-if="shot.imageUrl" :src="shot.imageUrl" />

      <!-- 生成中遮罩 -->
      <div v-if="shot.status === 'generating'" class="overlay">
        <LoadingSpinner />
        <span>AI 正在创作中...</span>
      </div>

      <!-- 错误提示 -->
      <div v-if="shot.status === 'error'" class="overlay error">
        <span>{{ shot.errorMessage }}</span>
        <button @click="retry">重试</button>
      </div>
    </div>

    <!-- 底部操作栏 -->
    <div class="shot-actions">
      <button
        @click="generate"
        :disabled="shot.status === 'generating'"
      >
        {{ buttonText }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
const buttonText = computed(() => {
  switch (shot.status) {
    case 'generating': return '生成中...'
    case 'error': return '重新生成'
    case 'generated': return '重新生成'
    default: return '生成图片'
  }
})
</script>

进阶:批量生成与并发控制

产品经理的需求没有尽头:“一键生成整个场景的所有分镜。”

小禾想:简单,Promise.all 搞定:

// ❌ 危险!10 个分镜同时请求,服务器可能扛不住
async function generateAllShots(sceneId: string) {
  const scene = storyStore.getSceneById(sceneId)

  await Promise.all(
    scene.shots.map(shot => generateShotImage(shot))
  )
}

后端同事发来消息:“你是不是在搞 DDoS 攻击?”

啊!原来是并发太高,GPU 被打满了。

解决方案:并发控制。

async function generateAllShots(sceneId: string) {
  const scene = storyStore.getSceneById(sceneId)
  if (!scene) return

  // 只处理需要生成的分镜
  const pendingShots = scene.shots.filter(
    s => s.status === 'idle' || s.status === 'error'
  )

  // 并发控制:最多同时 3 个
  const concurrency = 3
  const results = []

  for (let i = 0; i < pendingShots.length; i += concurrency) {
    const batch = pendingShots.slice(i, i + concurrency)

    // 用 allSettled 而不是 all,这样一个失败不会影响其他
    const batchResults = await Promise.allSettled(
      batch.map(shot => generateSingleShot(sceneId, shot.id))
    )

    results.push(...batchResults)
  }

  // 统计结果
  const succeeded = results.filter(r => r.status === 'fulfilled').length
  const failed = results.filter(r => r.status === 'rejected').length

  if (failed > 0) {
    ElMessage.warning(`生成完成: ${succeeded} 成功, ${failed} 失败`)
  } else {
    ElMessage.success(`全部 ${succeeded} 个分镜生成完成`)
  }
}

这样既保证了效率,又不会把服务器干崩。


进阶:取消生成

测试妹子又来了:“用户点了生成,但是 prompt 写错了,想取消怎么办?”

小禾意识到,长时间运行的操作需要取消机制

// 保存 AbortController 的引用
const abortController = ref<AbortController | null>(null)

async function generateShotImage() {
  const shot = storyStore.currentShot
  if (!shot) return

  // 创建取消控制器
  abortController.value = new AbortController()

  try {
    storyStore.updateShot(shot.sceneId, shot.id, { status: 'generating' })

    const result = await generateApi.generateShotImage({
      prompt: shot.prompt,
      signal: abortController.value.signal  // 传给 API
    })

    storyStore.updateShot(shot.sceneId, shot.id, {
      status: 'generated',
      imageUrl: result.data.imageUrl
    })

  } catch (error: any) {
    // 检查是否是主动取消
    if (error.name === 'AbortError') {
      console.log('生成已取消')
      storyStore.updateShot(shot.sceneId, shot.id, {
        status: 'idle'  // 回到空闲状态
      })
      return
    }

    // 其他错误正常处理
    storyStore.updateShot(shot.sceneId, shot.id, {
      status: 'error',
      errorMessage: error.message
    })

  } finally {
    abortController.value = null
  }
}

// 取消函数
function cancelGeneration() {
  abortController.value?.abort()
}

UI 上加个取消按钮:

<template>
  <div v-if="shot.status === 'generating'" class="generating-overlay">
    <LoadingSpinner />
    <span>生成中...</span>
    <button @click="cancel" class="cancel-btn">取消</button>
  </div>
</template>

终极挑战:多阶段生成流水线

产品经理放了个大招:“一键生成完整视频。”

小禾看了看流程:

生成完整视频:
1. 生成所有分镜图片(可能 10+ 个)
2. 为每个分镜生成动画(每个要 1-2 分钟)
3. 生成配音
4. 合成最终视频

每个阶段都可能失败,都需要显示进度。

这不再是一个简单的 loading 状态,而是一个状态机

interface VideoGenerationState {
  stage: 'idle' | 'images' | 'animations' | 'audio' | 'compositing' | 'done' | 'error'
  progress: {
    images: { total: number; completed: number }
    animations: { total: number; completed: number }
    audio: { status: GenerationStatus }
    video: { status: GenerationStatus }
  }
  error?: string
}

export const useVideoGenerationStore = defineStore('videoGeneration', () => {
  const state = ref<VideoGenerationState>({
    stage: 'idle',
    progress: {
      images: { total: 0, completed: 0 },
      animations: { total: 0, completed: 0 },
      audio: { status: 'idle' },
      video: { status: 'idle' }
    }
  })

  // 当前阶段的进度百分比
  const stageProgress = computed(() => {
    const { stage, progress } = state.value
    switch (stage) {
      case 'images':
        return progress.images.total > 0
          ? (progress.images.completed / progress.images.total) * 100
          : 0
      case 'animations':
        return progress.animations.total > 0
          ? (progress.animations.completed / progress.animations.total) * 100
          : 0
      case 'audio':
      case 'compositing':
        return 50  // 这两个阶段没有细分进度
      case 'done':
        return 100
      default:
        return 0
    }
  })

  // 总体进度(4 个阶段)
  const overallProgress = computed(() => {
    const stageWeights = { images: 30, animations: 40, audio: 15, compositing: 15 }
    const { stage, progress } = state.value

    let completed = 0

    // 已完成的阶段
    if (['animations', 'audio', 'compositing', 'done'].includes(stage)) {
      completed += stageWeights.images
    }
    if (['audio', 'compositing', 'done'].includes(stage)) {
      completed += stageWeights.animations
    }
    if (['compositing', 'done'].includes(stage)) {
      completed += stageWeights.audio
    }
    if (stage === 'done') {
      completed += stageWeights.compositing
    }

    // 当前阶段的进度
    if (stage === 'images') {
      completed += (progress.images.completed / progress.images.total) * stageWeights.images
    } else if (stage === 'animations') {
      completed += (progress.animations.completed / progress.animations.total) * stageWeights.animations
    }

    return Math.round(completed)
  })

  async function generateFullVideo(sceneId: string) {
    const scene = storyStore.getSceneById(sceneId)
    if (!scene) return

    try {
      // 阶段 1:生成所有图片
      state.value.stage = 'images'
      state.value.progress.images = { total: scene.shots.length, completed: 0 }

      for (const shot of scene.shots) {
        if (shot.status !== 'generated') {
          await generateShotImage(shot)
        }
        state.value.progress.images.completed++
      }

      // 阶段 2:生成所有动画
      state.value.stage = 'animations'
      state.value.progress.animations = { total: scene.shots.length, completed: 0 }

      for (const shot of scene.shots) {
        await generateShotAnimation(shot)
        state.value.progress.animations.completed++
      }

      // 阶段 3:生成配音
      state.value.stage = 'audio'
      state.value.progress.audio.status = 'generating'
      await generateAudio(sceneId)
      state.value.progress.audio.status = 'generated'

      // 阶段 4:合成视频
      state.value.stage = 'compositing'
      state.value.progress.video.status = 'generating'
      await compositeVideo(sceneId)
      state.value.progress.video.status = 'generated'

      // 完成
      state.value.stage = 'done'
      ElMessage.success('视频生成完成!')

    } catch (error: any) {
      state.value.stage = 'error'
      state.value.error = error.message
      ElMessage.error(`生成失败: ${error.message}`)
    }
  }

  function reset() {
    state.value = {
      stage: 'idle',
      progress: {
        images: { total: 0, completed: 0 },
        animations: { total: 0, completed: 0 },
        audio: { status: 'idle' },
        video: { status: 'idle' }
      }
    }
  }

  return {
    state,
    stageProgress,
    overallProgress,
    generateFullVideo,
    reset
  }
})

UI 展示多阶段进度:

<template>
  <div class="video-generation-modal">
    <h3>生成完整视频</h3>

    <!-- 阶段指示器 -->
    <div class="stage-indicator">
      <div
        v-for="s in stages"
        :key="s.key"
        :class="['stage', { active: state.stage === s.key, done: isStageCompleted(s.key) }]"
      >
        <span class="stage-icon">{{ getStageIcon(s.key) }}</span>
        <span class="stage-name">{{ s.label }}</span>
      </div>
    </div>

    <!-- 当前阶段详情 -->
    <div class="current-stage">
      <template v-if="state.stage === 'images'">
        正在生成图片 ({{ state.progress.images.completed }}/{{ state.progress.images.total }})
      </template>
      <template v-else-if="state.stage === 'animations'">
        正在生成动画 ({{ state.progress.animations.completed }}/{{ state.progress.animations.total }})
      </template>
      <template v-else-if="state.stage === 'audio'">
        正在生成配音...
      </template>
      <template v-else-if="state.stage === 'compositing'">
        正在合成视频...
      </template>
      <template v-else-if="state.stage === 'done'">
        生成完成!
      </template>
      <template v-else-if="state.stage === 'error'">
        生成失败: {{ state.error }}
      </template>
    </div>

    <!-- 进度条 -->
    <el-progress :percentage="overallProgress" :status="progressStatus" />

    <!-- 操作按钮 -->
    <div class="actions">
      <el-button
        v-if="state.stage === 'idle'"
        type="primary"
        @click="startGeneration"
      >
        开始生成
      </el-button>
      <el-button
        v-if="state.stage === 'error'"
        @click="retry"
      >
        重试
      </el-button>
    </div>
  </div>
</template>

状态管理清单

经过这一番折腾,小禾总结了一份清单:

场景解决方案
单个操作idle/generating/generated/error 四状态
多类型操作每种操作独立的状态变量
每个资源的状态状态存在资源对象上(如 shot.status
防止重复触发操作前检查状态
支持取消AbortController
批量操作并发控制 + Promise.allSettled
多阶段流程状态机 + 进度追踪
用户反馈不同状态不同 UI

小禾的感悟

一个 loading,
看似简单,
实则暗藏玄机。

true 和 false,
不足以描述世界,
idle、generating、generated、error,
才是完整的故事。

一个变量管不了多个操作,
每个操作要有自己的状态。
一个分镜有自己的 loading,
一个场景有自己的进度。

别忘了失败的情况,
别忘了取消的需求,
别忘了并发的控制,
别忘了用户的感受。

状态越复杂,
设计越要清晰。
状态机不是炫技,
而是驯服混乱的武器。

小禾保存代码,带着高烧长舒了一口气。

原来一个简单的"生成中…",背后有这么多学问。


下一篇预告:版本升级后老用户数据全挂了?一个迁移函数救命

数据结构变了,老用户的 localStorage 怎么办?

敬请期待。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员义拉冠

你的鼓励是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值