fixed bugs

This commit is contained in:
2026-09-13 12:58:45 +08:00
parent f810cfedd4
commit 5328898971
7 changed files with 233 additions and 61 deletions

View File

@@ -64,7 +64,7 @@ enum BoundaryState: Equatable {
case failed(message: String) case failed(message: String)
} }
/// 云端视频必须先完成整文件下载;下载未完成前不允许创建可播放的 AVPlayerItem。 /// 云端视频按需流式播放;若本地已有完整缓存文件则优先使用。
enum VideoCacheState: Equatable { enum VideoCacheState: Equatable {
case notRequired case notRequired
case downloading case downloading
@@ -76,9 +76,9 @@ enum VideoCacheState: Equatable {
case .notRequired, .ready: case .notRequired, .ready:
return nil return nil
case .downloading: case .downloading:
return "正在下载完整视频,下载完成后才能播放" return "正在缓冲视频…"
case .failed(let message): case .failed(let message):
return "视频缓存失败:\(message)" return "视频加载失败:\(message)"
} }
} }
} }

View File

@@ -31,8 +31,9 @@ final class PlayerController: ObservableObject {
private var swipeBackAnchorTime: TimeInterval = 0 private var swipeBackAnchorTime: TimeInterval = 0
private var speedBeforeLongPress: Float? private var speedBeforeLongPress: Float?
private var transientTask: Task<Void, Never>? private var transientTask: Task<Void, Never>?
private var cacheSwapTask: Task<Void, Never>? private var cacheFallbackTask: Task<Void, Never>?
private var activePlaybackURL: URL? private var activePlaybackURL: URL?
private var didAttemptFullFileFallback = false
private var sentenceEndTimeObserver: Any? private var sentenceEndTimeObserver: Any?
private var sentenceEndGuardToken = 0 private var sentenceEndGuardToken = 0
@@ -91,8 +92,9 @@ final class PlayerController: ObservableObject {
@MainActor @MainActor
func load(_ item: TrainingMediaItem) { func load(_ item: TrainingMediaItem) {
cancelSentenceBoundaryGuard() cancelSentenceBoundaryGuard()
cacheSwapTask?.cancel() cacheFallbackTask?.cancel()
cacheSwapTask = nil cacheFallbackTask = nil
didAttemptFullFileFallback = false
lastSentenceIndex = nil lastSentenceIndex = nil
swipeBackAnchorIndex = nil swipeBackAnchorIndex = nil
pendingSeekPositionMs = nil pendingSeekPositionMs = nil
@@ -110,12 +112,8 @@ final class PlayerController: ObservableObject {
cacheState = .ready cacheState = .ready
preparePlaybackItem(with: cachedURL) preparePlaybackItem(with: cachedURL)
} else { } else {
// 关键约束:云端视频没有完整本地文件前,不把 remote URL 交给 AVPlayer。 cacheState = .notRequired
activePlaybackURL = nil preparePlaybackItem(with: item.url)
cacheState = .downloading
player.replaceCurrentItem(with: nil)
tick()
scheduleFullFileDownload(for: item)
} }
} }
@@ -480,39 +478,8 @@ final class PlayerController: ObservableObject {
pendingSeekPositionMs ?? currentPositionMs() pendingSeekPositionMs ?? currentPositionMs()
} }
@MainActor
private func scheduleFullFileDownload(for item: TrainingMediaItem) {
let remoteURL = item.url
cacheSwapTask = Task { [weak self] in
let cachedURL: URL
do {
cachedURL = try await StreamingVideoCache.shared.prefetch(remoteURL)
} catch is CancellationError {
return
} catch {
self?.cacheState = .failed(message: error.localizedDescription)
self?.showTransientMessage("视频缓存失败:\(error.localizedDescription)")
return
}
self?.prepareDownloadedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL)
}
}
@MainActor
private func prepareDownloadedFileIfNeeded(remoteURL: URL, cachedURL: URL) {
guard !Task.isCancelled,
mediaItem?.url == remoteURL,
activePlaybackURL == nil else {
return
}
cacheState = .ready
preparePlaybackItem(with: cachedURL)
showTransientMessage("视频已完整缓存,可以播放")
}
private func preparePlaybackItem(with playbackURL: URL) { private func preparePlaybackItem(with playbackURL: URL) {
let newItem = AVPlayerItem(url: playbackURL) let newItem = makePlayerItem(url: playbackURL)
activePlaybackURL = playbackURL activePlaybackURL = playbackURL
attachItemObservers(to: newItem) attachItemObservers(to: newItem)
player.replaceCurrentItem(with: newItem) player.replaceCurrentItem(with: newItem)
@@ -524,6 +491,19 @@ final class PlayerController: ObservableObject {
tick() tick()
} }
private func makePlayerItem(url: URL) -> AVPlayerItem {
guard !url.isFileURL else {
return AVPlayerItem(url: url)
}
var options: [String: Any] = [:]
let ext = url.pathExtension.lowercased()
if ext.isEmpty || ["mp4", "m4v", "mov"].contains(ext) {
options["AVURLAssetOverrideMIMETypeKey"] = "video/mp4"
}
let asset = AVURLAsset(url: url, options: options.isEmpty ? nil : options)
return AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["playable", "tracks", "duration"])
}
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、 /// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
private func performSeek( private func performSeek(
@@ -844,12 +824,57 @@ final class PlayerController: ObservableObject {
if item.status == .failed { if item.status == .failed {
let message = item.error?.localizedDescription ?? "未知错误" let message = item.error?.localizedDescription ?? "未知错误"
self.showTransientMessage("播放失败:\(message)") self.showTransientMessage("播放失败:\(message)")
} else if item.status == .readyToPlay {
self.considerFullFileFallback(for: item)
} }
self.tick() self.tick()
} }
} }
} }
private func considerFullFileFallback(for item: AVPlayerItem) {
guard !didAttemptFullFileFallback,
let media = mediaItem,
!media.isLocalFile,
!(activePlaybackURL?.isFileURL ?? false) else {
return
}
let hasVisibleVideo = item.presentationSize.height >= 2 && item.presentationSize.width >= 2
if hasVisibleVideo {
return
}
didAttemptFullFileFallback = true
cacheFallbackTask?.cancel()
cacheFallbackTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 800_000_000)
guard let self, !Task.isCancelled else {
return
}
guard self.mediaItem?.id == media.id,
self.player.currentItem === item,
item.presentationSize.height < 2 else {
return
}
do {
let cachedURL = try await StreamingVideoCache.shared.prefetch(media.url)
guard self.mediaItem?.id == media.id else {
return
}
let shouldPlay = self.playbackIntent
let positionMs = self.pendingSeekPositionMs ?? self.currentPositionMs()
self.preparePlaybackItem(with: cachedURL)
self.cacheState = .ready
if positionMs > 0 {
self.performSeek(to: positionMs, resumePlayback: shouldPlay)
}
} catch is CancellationError {
return
} catch {
self.showTransientMessage("视频画面加载失败:\(error.localizedDescription)")
}
}
}
private func configureAudioSession() { private func configureAudioSession() {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback) try? session.setCategory(.playback, mode: .moviePlayback)

View File

@@ -13,15 +13,20 @@ struct PlayerSurfaceView: UIViewRepresentable {
func makeUIView(context: Context) -> VideoSurfaceView { func makeUIView(context: Context) -> VideoSurfaceView {
let view = VideoSurfaceView() let view = VideoSurfaceView()
view.playerLayer.player = controller.player
view.controller = controller view.controller = controller
view.onVerticalSwipe = onVerticalSwipe view.onVerticalSwipe = onVerticalSwipe
view.attach(player: controller.player)
view.setContentHuggingPriority(.defaultLow, for: .vertical)
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view return view
} }
func updateUIView(_ uiView: VideoSurfaceView, context: Context) { func updateUIView(_ uiView: VideoSurfaceView, context: Context) {
uiView.playerLayer.player = controller.player uiView.controller = controller
uiView.onVerticalSwipe = onVerticalSwipe uiView.onVerticalSwipe = onVerticalSwipe
uiView.attach(player: controller.player)
} }
} }
@@ -29,13 +34,9 @@ final class VideoSurfaceView: UIView {
weak var controller: PlayerController? weak var controller: PlayerController?
var onVerticalSwipe: ((Bool) -> Void)? var onVerticalSwipe: ((Bool) -> Void)?
override class var layerClass: AnyClass { private let videoLayer = AVPlayerLayer()
AVPlayerLayer.self
}
var playerLayer: AVPlayerLayer { var playerLayer: AVPlayerLayer { videoLayer }
layer as! AVPlayerLayer
}
private let minSwipeDistance: CGFloat = 48 private let minSwipeDistance: CGFloat = 48
private let minSwipeVelocity: CGFloat = 160 private let minSwipeVelocity: CGFloat = 160
@@ -55,7 +56,11 @@ final class VideoSurfaceView: UIView {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .black backgroundColor = .black
playerLayer.videoGravity = .resizeAspect isOpaque = true
clipsToBounds = true
videoLayer.videoGravity = .resizeAspect
videoLayer.backgroundColor = UIColor.black.cgColor
layer.addSublayer(videoLayer)
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
@@ -82,6 +87,25 @@ final class VideoSurfaceView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer.frame = bounds
CATransaction.commit()
}
func attach(player: AVPlayer) {
if videoLayer.player !== player {
videoLayer.player = player
}
if videoLayer.superlayer !== layer {
layer.addSublayer(videoLayer)
}
videoLayer.frame = bounds
videoLayer.isHidden = false
}
@objc private func handleTap() { @objc private func handleTap() {
controller?.handleGesture(.singleTap) controller?.handleGesture(.singleTap)
} }
@@ -96,7 +120,6 @@ final class VideoSurfaceView: UIView {
case .began: case .began:
resetSwipeTracking() resetSwipeTracking()
case .changed: case .changed:
// 在滑动达到阈值时立即派发,并且同一个手势只允许派发一次。
evaluateSwipe(dx: dx, dy: dy, velocity: velocity) evaluateSwipe(dx: dx, dy: dy, velocity: velocity)
case .ended, .cancelled, .failed: case .ended, .cancelled, .failed:
if !didDispatchSwipe { if !didDispatchSwipe {
@@ -112,7 +135,6 @@ final class VideoSurfaceView: UIView {
guard !didDispatchSwipe else { return } guard !didDispatchSwipe else { return }
if swipeAxis == nil { if swipeAxis == nil {
// 只有方向足够明确时锁定轴向,避免轻微斜滑被误判为上下滑。
if abs(dx) > abs(dy) * 1.2 { if abs(dx) > abs(dy) * 1.2 {
swipeAxis = .horizontal swipeAxis = .horizontal
} else if abs(dy) > abs(dx) * 1.2 { } else if abs(dy) > abs(dx) * 1.2 {

View File

@@ -1,11 +1,10 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
/// iOS 端的持久化整文件视频缓存。 /// iOS 端的可选整文件视频缓存。
/// ///
/// AVFoundation 没有公开可持久化的媒体字节缓存 API。这里先把远程视频完整 /// 播放默认走远程 URL 按需加载(AVPlayer HTTP Range)。若本地已有完整缓存文件,
/// 下载成原子本地文件;只有文件 /// 播放器会优先使用该文件,避免重复拉流。
/// 完整落盘后才返回给播放器,避免用部分响应或播放列表创建可播放的 AVPlayerItem。
@MainActor @MainActor
final class StreamingVideoCache { final class StreamingVideoCache {
static let shared = StreamingVideoCache() static let shared = StreamingVideoCache()

View File

@@ -481,9 +481,13 @@ def create_app(
media_path = service_settings.videos_dir / row["stored_filename"] media_path = service_settings.videos_dir / row["stored_filename"]
if not media_path.is_file(): if not media_path.is_file():
raise HTTPException(status_code=404, detail="Stored video file is missing.") raise HTTPException(status_code=404, detail="Stored video file is missing.")
media_type = row.get("content_type") or "video/mp4"
if not str(media_type).lower().startswith("video/"):
guessed = mimetypes.guess_type(str(row.get("stored_filename") or ""))[0]
media_type = guessed if guessed and guessed.lower().startswith("video/") else "video/mp4"
return FileResponse( return FileResponse(
media_path, media_path,
media_type=row["content_type"], media_type=media_type,
headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"}, headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"},
) )

View File

@@ -1,10 +1,11 @@
import json
import logging import logging
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from .audio_metrics import ( from .audio_metrics import (
AudioAnalysisError, AudioAnalysisError,
@@ -85,6 +86,126 @@ def _ensure_webm_cues_front(filepath: Path) -> None:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
_IOS_VIDEO_CODECS = {"h264", "hevc"}
_IOS_AUDIO_CODECS = {"aac", "mp3", "ac3", "eac3", "alac"}
def _probe_stream_codecs(filepath: Path) -> Tuple[Optional[str], Optional[str]]:
ffprobe = shutil.which("ffprobe")
if ffprobe is None:
return None, None
completed = subprocess.run(
[
ffprobe,
"-hide_banner",
"-loglevel",
"error",
"-show_streams",
"-of",
"json",
str(filepath),
],
capture_output=True,
text=True,
timeout=60,
)
if completed.returncode != 0:
return None, None
try:
payload = json.loads(completed.stdout or "{}")
except json.JSONDecodeError:
return None, None
video_codec = None
audio_codec = None
for stream in payload.get("streams") or []:
codec_type = stream.get("codec_type")
codec_name = stream.get("codec_name")
if codec_type == "video" and video_codec is None and codec_name not in {"mjpeg", "png"}:
video_codec = codec_name
elif codec_type == "audio" and audio_codec is None:
audio_codec = codec_name
return video_codec, audio_codec
def _needs_ios_compatible_transcode(video_codec: Optional[str], audio_codec: Optional[str]) -> bool:
if video_codec is None and audio_codec is None:
return False
video_ok = video_codec is None or video_codec in _IOS_VIDEO_CODECS
audio_ok = audio_codec is None or audio_codec in _IOS_AUDIO_CODECS
return not (video_ok and audio_ok)
def _ensure_ios_compatible_media(filepath: Path) -> None:
"""Transcode AV1/VP9/Opus etc. to H.264/AAC so iOS AVPlayer can show video."""
video_codec, audio_codec = _probe_stream_codecs(filepath)
if not _needs_ios_compatible_transcode(video_codec, audio_codec):
return
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
logger.warning(
"ffmpeg not found; skipping iOS-compatible transcode for %s (%s/%s)",
filepath.name,
video_codec,
audio_codec,
)
return
tmp = filepath.with_suffix(".ios_tmp.mp4")
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(filepath),
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-vf",
"scale='min(1920,iw)':-2",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-ac",
"2",
"-movflags",
"+faststart",
str(tmp),
]
logger.info(
"Transcoding %s for iOS playback (%s/%s -> h264/aac)",
filepath.name,
video_codec,
audio_codec,
)
try:
completed = subprocess.run(command, capture_output=True, text=True, timeout=7200)
if completed.returncode == 0 and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(filepath)
logger.info("iOS-compatible transcode finished: %s", filepath.name)
return
logger.warning(
"iOS-compatible transcode failed for %s: rc=%d stderr=%s",
filepath.name,
completed.returncode,
(completed.stderr or "")[-400:],
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.warning("iOS-compatible transcode error for %s: %s", filepath.name, exc)
finally:
tmp.unlink(missing_ok=True)
class VideoProcessor: class VideoProcessor:
def __init__( def __init__(
self, self,
@@ -110,6 +231,7 @@ class VideoProcessor:
work_path: Optional[Path] = None work_path: Optional[Path] = None
try: try:
_ensure_webm_cues_front(media_path) _ensure_webm_cues_front(media_path)
_ensure_ios_compatible_media(media_path)
if self.transcriber.available: if self.transcriber.available:
work_path = self.settings.work_dir / f"{video_hash}-{uuid.uuid4().hex}.wav" work_path = self.settings.work_dir / f"{video_hash}-{uuid.uuid4().hex}.wav"
extract_audio(media_path, work_path) extract_audio(media_path, work_path)