iOS视频自动全屏问题解决方案

iOS 视频自动全屏问题解决方案 --- 技术分享

一、问题背景

在移动端 H5 开发中,视频播放是常见需求。然而 iOS Safari 对 <video> 元素有一项硬性限制:

调用 video.play() 时,iOS 会自动将视频切换到系统级全屏播放器,用户无法在页面内继续浏览其他内容。

这在轮播视频、嵌入式视频播放器等场景下体验极差------用户点击播放,整个页面被系统播放器接管,播放完毕才能回到页面。

影响范围

  • iOS Safari 所有版本均存在此行为
  • iOS 10+ 虽然引入了 playsinline 属性,但部分机型和 WebView 环境下仍会强制全屏
  • 微信内置浏览器、WKWebView 等场景同样受影响

二、常规方案的局限性

方案一:playsinline 属性

html 复制代码
<video playsinline webkit-playsinline></video>
  • iOS 10+ 支持,理论上可禁止自动全屏
  • 局限性:部分低端机型、特定 WebView 下仍会全屏,稳定性无法保证
  • 仅解决全屏问题,无法精细控制播放器 UI

方案二:iphone-inline-video

js 复制代码
import makeVideoPlayableInline from 'iphone-inline-video'
makeVideoPlayableInline(videoElement)
  • 通过 hack 方式绕过 WebKit 全屏强制逻辑
  • 局限性:hack 方式存在伪事件副作用,需要额外处理

三、最终方案:Canvas 帧绘制代理

3.1 核心思路

偷梁换柱------用户看到的不是 <video>,而是 <canvas>

复制代码
┌─────────────────────────────────────┐
│  .video-canvas-container            │
│  ├── <video>  opacity:0, z-index:0  │  ← 数据源(不可见)
│  └── <canvas> z-index:1             │  ← 可见渲染层
└─────────────────────────────────────┘
  • <video> 元素设为 opacity: 0,隐藏在后台,仅作为帧数据源
  • <canvas> 元素叠在上方,通过 ctx.drawImage(video, ...) 逐帧绘制视频画面
  • 用户看到和交互的都是 canvas,canvas 不是媒体元素,iOS 不会触发全屏

3.2 技术架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                     VideoSwiper.vue                          │
│  (轮播调度层:方案选择、轮播控制、状态管理)                   │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │              van-swipe (Vant 轮播容器)                 │   │
│  │  ┌───────────┐  ┌───────────┐  ┌───────────┐       │   │
│  │  │  Item 0   │  │  Item 1   │  │  Item 2   │  ...  │   │
│  │  │           │  │           │  │           │       │   │
│  │  │ [方案A]   │  │ [方案A]   │  │ [方案A]   │       │   │
│  │  │VideoToCanvas│VideoToCanvas│VideoToCanvas│       │   │
│  │  │ (独立组件) │  │ (独立组件) │  │ (独立组件) │       │   │
│  │  │           │  │           │  │           │       │   │
│  │  │ [方案B]   │  │ [方案B]   │  │ [方案B]   │       │   │
│  │  │ xgplayer  │  │ xgplayer  │  │ xgplayer  │       │   │
│  │  └───────────┘  └───────────┘  └───────────┘       │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                  VideoToCanvas.vue(独立组件)                │
│  (Canvas 播放层:video+canvas、绘制循环、伪事件防御)         │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  .video-canvas-container                              │   │
│  │  ├── <video> (opacity:0, z-index:0)  ← 数据源        │   │
│  │  └── <canvas> (z-index:1)            ← 可见渲染层    │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  暴露方法: play / pause / destroy / init / resize /          │
│           isPaused / isEnded / getCurrentTime / setCurrentTime│
│  上报事件: play / pause / ended / error / loadedmetadata /   │
│           canplay / ready                                    │
└─────────────────────────────────────────────────────────────┘

3.3 方案选择逻辑

通过 useCanvasFallback 计算属性自动判断走哪条路径:

js 复制代码
const useCanvasFallback = computed(() => {
  if (!isIOS()) return false
  const version = getIOSMajorVersion()
  return version >= 10
})
方案 条件 渲染组件 可见元素
方案A: iOS Canvas iOS 10+ <VideoToCanvas> Canvas(video 透明)
方案B: xgplayer 非 iOS xgplayer 实例 xgplayer 内部 video

四、Canvas 帧绘制实现

4.1 绘制流程

js 复制代码
startCanvasDraw()
  ├── 1. stopCanvasDraw()       // 先停止旧循环
  ├── 2. 获取 video/canvas/ctx
  ├── 3. updateCanvasSize()     // canvas 尺寸适配容器 × devicePixelRatio
  └── 4. drawFrame() 递归循环
       ├── 终止条件: video.ended && video.paused → return
       ├── 跳过条件: video.readyState < 2 → rAF 继续(等帧数据)
       ├── 绘制:
       │    ctx.clearRect → fillRect(黑底) → drawImage(video, contain居中)
       └── requestAnimationFrame(drawFrame) → animationFrameId

4.2 核心:drawImage 逐帧绘制

整个方案的核心就是浏览器原生 API drawImage

js 复制代码
function drawFrame() {
  if (video.ended && video.paused) return

  if (video.readyState < 2) {
    // 帧数据未就绪,等待下一帧
    animationFrameId = requestAnimationFrame(drawFrame)
    return
  }

  ctx.clearRect(0, 0, canvasW, canvasH)
  ctx.fillStyle = '#000'
  ctx.fillRect(0, 0, canvasW, canvasH)

  // contain 居中绘制
  const scale = Math.min(canvasW / videoW, canvasH / videoH)
  const drawX = (canvasW - videoW * scale) / 2
  const drawY = (canvasH - videoH * scale) / 2
  ctx.drawImage(video, drawX, drawY, videoW * scale, videoH * scale)

  animationFrameId = requestAnimationFrame(drawFrame)
}

4.3 高 DPI 适配

移动端屏幕像素密度高,需适配 devicePixelRatio 避免 canvas 模糊:

js 复制代码
canvas.width = containerWidth * devicePixelRatio
canvas.height = containerHeight * devicePixelRatio
canvas.style.width = containerWidth + 'px'
canvas.style.height = containerHeight + 'px'
ctx.scale(devicePixelRatio, devicePixelRatio)

五、关键难点:伪事件防御

5.1 问题

iphone-inline-video 库通过 hack 实现 video 内联播放,但会触发伪事件链:

复制代码
video.play() → 伪 play → 伪 pause → 伪 ended

此时 video.paused 始终为 true,但 Canvas 绘制循环仍在正常工作(视频实际上在播放)。如果不过滤这些伪事件,播放状态管理会完全混乱。

5.2 防御机制

引入 intentionalPlayState 标记主动播放意图,配合双层条件过滤:

复制代码
                      intentionalPlayState
                      { timestamp }
                            │
                            ▼
┌──────────── isIntentionalPlay() ────────────────────┐
│  1. 无状态 → false                                    │
│  2. timestamp 超时(>500ms) → 清除状态, return false   │
│  3. timestamp 在窗口内 → return true                  │
└─────────────────────────────────────────────────────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
         onPlay         onPause       onEnded
              │             │             │
         paused=true    isIntentionalPlay   isIntentionalPlay
         + isIntentional  + currentTime<1    + currentTime<1
              │             │             │
         → 接受play     → 忽略伪pause   → 忽略伪ended
         (清除状态)     → 正常处理       → 正常处理

5.3 代码实现

js 复制代码
// 主动播放时记录时间戳
const intentionalPlayState = { timestamp: 0 }

function play() {
  intentionalPlayState.timestamp = Date.now()
  video.play()
}

function isIntentionalPlay() {
  if (!intentionalPlayState.timestamp) return false
  if (Date.now() - intentionalPlayState.timestamp > 500) {
    intentionalPlayState.timestamp = 0
    return false
  }
  return true
}

// onPause / onEnded 中过滤伪事件
function onPause() {
  if (isIntentionalPlay() && video.currentTime < 1) {
    // 主动播放窗口内 + 视频刚开始 → 伪事件,忽略
    return
  }
  // 正常暂停逻辑
  emit('pause')
}

为什么需要 currentTime < 1 这一层?

500ms 时间窗口可能不够精准,但如果视频 currentTime < 1(刚播放不到 1 秒),就不可能正常结束,一定是伪事件。视频正常播完时 currentTime ≈ duration,不会被误判。


六、为什么这个方案能解决问题?

层面 原因
iOS 检测目标 iOS 全屏逻辑只针对 <video> DOM 元素触发,<canvas> 不是媒体元素,不触发全屏
video 不可见 opacity: 0 + z-index: 0,用户看不到 video,也就感知不到全屏切换
canvas 可见 用户交互的都是 canvas,canvas 没有"全屏"概念,就是一个普通 DOM 元素
数据流通 drawImage(video, ...) 是浏览器原生 API,可以直接从 video 读取当前帧像素数据,无需手动解码
iphone-inline-video 确保 video 在后台能正常推进 currentTime,提供持续帧数据

一句话总结:video 在后台默默播放提供帧数据,canvas 在前台显示画面,iOS 看不到 video 自然不会触发全屏。


七、方案代价与局限

维度 说明
CPU 开销 requestAnimationFrame 持续调用 drawImage,CPU 占用高于原生 video 播放
无原生控件 canvas 不支持原生播放控件(进度条、音量等),需要自行实现
iphone-inline-video 维护 该库通过 hack 实现,iOS 大版本更新可能导致失效
内存占用 video + canvas 双层渲染,内存占用略高
兼容范围 方案仅在 iOS 10+ 生效,iOS 9 及以下无法使用

八、测试 Demo

以下提供完整的可运行 Demo,复制为 HTML 文件即可在 iOS Safari 中测试验证。

8.1 原生 HTML 版(最简 Demo)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
  <title>iOS Canvas 视频内联播放 Demo</title>
  <script src="https://unpkg.com/iphone-inline-video@2.2.2/build/iphone-inline-video.bundle.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #f3f3f3; font-family: -apple-system, sans-serif; padding: 16px; }

    .video-canvas-container {
      position: relative;
      width: 100%;
      height: 220px;
      background: #000;
      border-radius: 12px;
      overflow: hidden;
    }

    .video-canvas-container video {
      position: absolute;
      top: 0; left: 0;
      width: 100%; height: 100%;
      opacity: 0;
      z-index: 0;
      object-fit: contain;
    }

    .video-canvas-container canvas {
      position: absolute;
      top: 0; left: 0;
      width: 100%; height: 100%;
      z-index: 1;
    }

    .controls {
      display: flex;
      gap: 12px;
      margin-top: 16px;
      justify-content: center;
    }

    .controls button {
      padding: 10px 28px;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      font-weight: 600;
      cursor: pointer;
    }

    .btn-play { background: #1677ff; color: #fff; }
    .btn-pause { background: #999; color: #fff; }
    .btn-replay { background: #52c41a; color: #fff; }

    .status {
      text-align: center;
      margin-top: 12px;
      font-size: 14px;
      color: #666;
    }

    .notice {
      margin-top: 20px;
      padding: 12px;
      background: #fff;
      border-radius: 8px;
      font-size: 13px;
      color: #999;
      line-height: 1.6;
    }
  </style>
</head>
<body>
  <h2 style="text-align:center; margin-bottom:16px;">iOS Canvas 视频内联播放</h2>

  <div class="video-canvas-container" id="container">
    <video id="video" playsinline webkit-playsinline preload="auto"
           src="https://www.w3schools.com/html/mov_bbb.mp4"></video>
    <canvas id="canvas"></canvas>
  </div>

  <div class="controls">
    <button class="btn-play" onclick="playVideo()">播放</button>
    <button class="btn-pause" onclick="pauseVideo()">暂停</button>
    <button class="btn-replay" onclick="replayVideo()">重播</button>
  </div>

  <div class="status" id="status">状态:未播放</div>

  <div class="notice">
    💡 请在 iOS Safari 中打开此页面测试。点击播放后,视频将在页面内播放,不会自动进入全屏。
  </div>

  <script>
    const video = document.getElementById('video')
    const canvas = document.getElementById('canvas')
    const ctx = canvas.getContext('2d')
    const container = document.getElementById('container')
    const statusEl = document.getElementById('status')

    let animationFrameId = null
    let intentionalPlayTimestamp = 0

    // ========== 初始化 iphone-inline-video ==========
    if (typeof makeVideoPlayableInline === 'function') {
      makeVideoPlayableInline(video)
    }

    // ========== Canvas 尺寸适配 ==========
    function updateCanvasSize() {
      const dpr = window.devicePixelRatio || 1
      const rect = container.getBoundingClientRect()
      canvas.width = rect.width * dpr
      canvas.height = rect.height * dpr
      canvas.style.width = rect.width + 'px'
      canvas.style.height = rect.height + 'px'
      ctx.scale(dpr, dpr)
      return { width: rect.width, height: rect.height }
    }

    // ========== 绘制循环 ==========
    function drawFrame() {
      if (video.ended && video.paused) {
        setStatus('播放结束')
        return
      }

      if (video.readyState < 2) {
        animationFrameId = requestAnimationFrame(drawFrame)
        return
      }

      const dpr = window.devicePixelRatio || 1
      const cw = canvas.width / dpr
      const ch = canvas.height / dpr

      ctx.clearRect(0, 0, cw, ch)
      ctx.fillStyle = '#000'
      ctx.fillRect(0, 0, cw, ch)

      // contain 居中绘制
      const vw = video.videoWidth || 1
      const vh = video.videoHeight || 1
      const scale = Math.min(cw / vw, ch / vh)
      const dx = (cw - vw * scale) / 2
      const dy = (ch - vh * scale) / 2

      ctx.drawImage(video, dx, dy, vw * scale, vh * scale)

      animationFrameId = requestAnimationFrame(drawFrame)
    }

    function stopDraw() {
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId)
        animationFrameId = null
      }
    }

    // ========== 伪事件防御 ==========
    function isIntentionalPlay() {
      if (!intentionalPlayTimestamp) return false
      if (Date.now() - intentionalPlayTimestamp > 500) {
        intentionalPlayTimestamp = 0
        return false
      }
      return true
    }

    video.addEventListener('play', () => {
      if (video.paused && isIntentionalPlay()) {
        // 主动播放后的伪 play,接受并启动绘制
        intentionalPlayTimestamp = 0
      }
      startDraw()
      setStatus('播放中')
    })

    video.addEventListener('pause', () => {
      if (isIntentionalPlay() && video.currentTime < 1) {
        // 伪 pause,忽略
        return
      }
      stopDraw()
      setStatus('已暂停')
    })

    video.addEventListener('ended', () => {
      if (isIntentionalPlay() && video.currentTime < 1) {
        // 伪 ended,忽略
        return
      }
      stopDraw()
      setStatus('播放结束')
    })

    video.addEventListener('error', () => {
      setStatus('视频加载失败')
    })

    // ========== 播放控制 ==========
    function startDraw() {
      stopDraw()
      updateCanvasSize()
      drawFrame()
    }

    function playVideo() {
      intentionalPlayTimestamp = Date.now()
      video.play()
    }

    function pauseVideo() {
      video.pause()
    }

    function replayVideo() {
      video.currentTime = 0
      intentionalPlayTimestamp = Date.now()
      video.play()
    }

    function setStatus(text) {
      statusEl.textContent = '状态:' + text
    }

    // 页面可见性处理
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        pauseVideo()
      }
    })
  </script>
</body>
</html>

8.2 Vue 3 组件版(VideoToCanvas)

vue 复制代码
<template>
  <div class="video-canvas-container" ref="containerRef">
    <video
      ref="videoRef"
      playsinline
      webkit-playsinline
      preload="auto"
      :src="src"
      :poster="poster"
      @play="onPlay"
      @pause="onPause"
      @ended="onEnded"
      @error="onError"
      @loadedmetadata="onLoadedMetadata"
      @canplay="onCanplay"
    />
    <canvas ref="canvasRef" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'

// 安装: npm install iphone-inline-video
import makeVideoPlayableInline from 'iphone-inline-video'

const props = defineProps<{
  src: string
  poster?: string
}>()

const emit = defineEmits<{
  play: []
  pause: []
  ended: []
  error: [payload: { code: number; message: string; url: string }]
  loadedmetadata: [payload: { videoWidth: number; videoHeight: number; duration: number }]
  canplay: []
  ready: []
}>()

const containerRef = ref<HTMLDivElement>()
const videoRef = ref<HTMLVideoElement>()
const canvasRef = ref<HTMLCanvasElement>()

let ctx: CanvasRenderingContext2D | null = null
let animationFrameId: number | null = null
let intentionalPlayTimestamp = 0
let isDestroyed = false

// ========== 伪事件防御 ==========

function isIntentionalPlay(): boolean {
  if (!intentionalPlayTimestamp) return false
  if (Date.now() - intentionalPlayTimestamp > 500) {
    intentionalPlayTimestamp = 0
    return false
  }
  return true
}

// ========== Canvas 尺寸适配 ==========

function updateCanvasSize() {
  if (!containerRef.value || !canvasRef.value || !ctx) return
  const dpr = window.devicePixelRatio || 1
  const rect = containerRef.value.getBoundingClientRect()
  canvasRef.value.width = rect.width * dpr
  canvasRef.value.height = rect.height * dpr
  canvasRef.value.style.width = rect.width + 'px'
  canvasRef.value.style.height = rect.height + 'px'
  ctx.setTransform(1, 0, 0, 1, 0, 0) // 重置 scale
  ctx.scale(dpr, dpr)
}

// ========== 绘制循环 ==========

function startCanvasDraw() {
  stopCanvasDraw()
  if (!videoRef.value || !canvasRef.value || !ctx) return

  updateCanvasSize()
  drawFrame()
}

function drawFrame() {
  const video = videoRef.value!
  const canvas = canvasRef.value!

  if (video.ended && video.paused) {
    stopCanvasDraw()
    return
  }

  if (video.readyState < 2) {
    animationFrameId = requestAnimationFrame(drawFrame)
    return
  }

  const dpr = window.devicePixelRatio || 1
  const cw = canvas.width / dpr
  const ch = canvas.height / dpr

  ctx!.clearRect(0, 0, cw, ch)
  ctx!.fillStyle = '#000'
  ctx!.fillRect(0, 0, cw, ch)

  const vw = video.videoWidth || 1
  const vh = video.videoHeight || 1
  const scale = Math.min(cw / vw, ch / vh)
  const dx = (cw - vw * scale) / 2
  const dy = (ch - vh * scale) / 2

  ctx!.drawImage(video, dx, dy, vw * scale, vh * scale)
  animationFrameId = requestAnimationFrame(drawFrame)
}

function stopCanvasDraw() {
  if (animationFrameId !== null) {
    cancelAnimationFrame(animationFrameId)
    animationFrameId = null
  }
}

// ========== video 事件处理 ==========

function onPlay() {
  const video = videoRef.value!
  if (video.paused && isIntentionalPlay()) {
    intentionalPlayTimestamp = 0
  }
  startCanvasDraw()
  emit('play')
}

function onPause() {
  if (isIntentionalPlay() && videoRef.value!.currentTime < 1) return
  stopCanvasDraw()
  emit('pause')
}

function onEnded() {
  if (isIntentionalPlay() && videoRef.value!.currentTime < 1) return
  stopCanvasDraw()
  emit('ended')
}

function onError() {
  const video = videoRef.value!
  const error = video.error
  emit('error', {
    code: error?.code || 0,
    message: error?.message || 'Unknown error',
    url: props.src,
  })
}

function onLoadedMetadata() {
  const video = videoRef.value!
  emit('loadedmetadata', {
    videoWidth: video.videoWidth,
    videoHeight: video.videoHeight,
    duration: video.duration,
  })
}

function onCanplay() {
  emit('canplay')
}

// ========== 暴露方法 ==========

function play() {
  intentionalPlayTimestamp = Date.now()
  videoRef.value?.play()
}

function pause() {
  if (videoRef.value && !videoRef.value.ended) {
    videoRef.value.pause()
  }
  stopCanvasDraw()
}

function destroy() {
  isDestroyed = true
  stopCanvasDraw()
  if (videoRef.value) {
    videoRef.value.src = ''
    videoRef.value.load()
  }
}

function init() {
  if (!videoRef.value || !props.src) return
  // 初始化 iphone-inline-video
  makeVideoPlayableInline(videoRef.value)
  // 初始化 canvas context
  ctx = canvasRef.value!.getContext('2d')
  emit('ready')
}

function isPaused(): boolean {
  return videoRef.value?.paused ?? true
}

function isEnded(): boolean {
  return videoRef.value?.ended ?? false
}

function getCurrentTime(): number {
  return videoRef.value?.currentTime ?? 0
}

function setCurrentTime(time: number) {
  if (videoRef.value) {
    videoRef.value.currentTime = time
  }
}

function resize() {
  stopCanvasDraw()
  startCanvasDraw()
}

defineExpose({
  play,
  pause,
  destroy,
  init,
  resize,
  isPaused,
  isEnded,
  getCurrentTime,
  setCurrentTime,
})

// ========== 生命周期 ==========

onMounted(() => {
  nextTick(() => init())
})

onBeforeUnmount(() => {
  destroy()
})

// 监听 src 变化
watch(
  () => props.src,
  () => {
    if (props.src) {
      nextTick(() => init())
    }
  }
)
</script>

<style scoped>
.video-canvas-container {
  position: relative;
  width: 100%;
  height: 220px;
  background: #000;
  border-radius: 12px;
  overflow: hidden;
}

.video-canvas-container video {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  z-index: 0;
  object-fit: contain;
}

.video-canvas-container canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
}
</style>

8.3 在父组件中使用

vue 复制代码
<template>
  <VideoToCanvas
    :src="videoUrl"
    :poster="posterUrl"
    @play="onPlay"
    @pause="onPause"
    @ended="onEnded"
    @error="onError"
    @ready="onReady"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import VideoToCanvas from './VideoToCanvas.vue'

const videoUrl = ref('https://www.w3schools.com/html/mov_bbb.mp4')
const posterUrl = ref('https://www.w3schools.com/html/mov_bbb.jpg')

function onPlay() { console.log('播放') }
function onPause() { console.log('暂停') }
function onEnded() { console.log('播放结束') }
function onError(e: any) { console.error('错误:', e) }
function onReady() { console.log('就绪') }
</script>

8.4 依赖安装

bash 复制代码
# 安装 iphone-inline-video
npm install iphone-inline-video

注意 :纯 HTML Demo 无需安装,通过 CDN 引入即可。Vue 组件版需要安装 iphone-inline-video 依赖。


九、总结

复制代码
iOS 视频自动全屏问题
  │
  ├── 根因: WebKit 对 <video> 元素强制全屏
  │
  ├── 解法: 偷梁换柱
  │     ├── video 隐藏做数据源(opacity:0)
  │     ├── canvas 前台做展示(drawImage 逐帧绘制)
  │     └── iphone-inline-video 确保 video 能内联播放
  │
  ├── 难点: 伪事件防御
  │     ├── 500ms 时间窗口标记主动播放意图
  │     └── currentTime < 1 双层过滤伪 pause/ended
  │
  └── 代价: CPU 开销增加、无原生控件、hack 维护风险

该方案本质是利用 Canvas 作为"代理渲染层",绕过了 iOS 对 video 元素的全屏限制。虽然有一定的性能代价和兼容风险,但在 iOS 视频内联播放这一刚需场景下,是目前最可靠的解决方案。

相关推荐
牛大兵1 小时前
播放网络摄像头视频支持ONVIF/RTSP
网络·python·音视频
这是程序猿2 小时前
ComfyUI 教程合集|AI绘图、ControlNet、Lora、IPAdapter、视频生成全攻略
大数据·人工智能·windows·音视频
Bug 挖掘机3 小时前
从0到1做出可复用的 iOS 自动化测试 Skill,附真机演示效果
自动化测试·测试开发·ios
掘根3 小时前
【微服务即时通讯】客户端通信连接
ios·iphone
ai产品老杨4 小时前
解构企业级AI视频中台:基于X86/ARM与GPU/NPU异构架构的深度演进与源码交付实践
arm开发·人工智能·音视频
AI服务老曹4 小时前
打破设备割裂:基于 GB28181 与 RTSP 的边缘计算 AI 视频平台架构解析(附源码交付与 Docker 部署)
人工智能·音视频·边缘计算
00后程序员张4 小时前
完整指南 iOS App上架到App Store的步骤详解
macos·ios·小程序·uni-app·objective-c·cocoa·iphone
AI服务老曹4 小时前
深度解析:支持异构计算与 Docker 部署的 AI 视频管理平台——基于 GB28181/RTSP 与源码交付的架构实战
人工智能·docker·音视频
maaath4 小时前
【maaath】Flutter for OpenHarmony 跨平台工程集成音视频播放能力实战
flutter·华为·音视频·harmonyos