From 5328898971c1284d0b6a4cd3b4d3378be9c9d496 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sun, 13 Sep 2026 12:58:45 +0800 Subject: [PATCH] fixed bugs --- .../UserInterfaceState.xcuserstate | Bin 23700 -> 23724 bytes ios/OralTrainer/Models/PlaybackModels.swift | 6 +- ios/OralTrainer/Player/PlayerController.swift | 107 +++++++++------ .../Player/PlayerSurfaceView.swift | 44 +++++-- .../Services/StreamingVideoCache.swift | 7 +- sentence_api/main.py | 6 +- sentence_api/processing.py | 124 +++++++++++++++++- 7 files changed, 233 insertions(+), 61 deletions(-) diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index a8b47828a4122cc3d87260213a818202589c4cd5..9f45dcdb8abf53f4feeab0b5b67cee07d5d808f5 100644 GIT binary patch delta 3131 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 YTYOV|^RMr<$@HryU;owruko$_1xoD~0{{R3 delta 3057 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 diff --git a/ios/OralTrainer/Models/PlaybackModels.swift b/ios/OralTrainer/Models/PlaybackModels.swift index 7bb4584..f320b30 100644 --- a/ios/OralTrainer/Models/PlaybackModels.swift +++ b/ios/OralTrainer/Models/PlaybackModels.swift @@ -64,7 +64,7 @@ enum BoundaryState: Equatable { case failed(message: String) } -/// 云端视频必须先完成整文件下载;下载未完成前不允许创建可播放的 AVPlayerItem。 +/// 云端视频按需流式播放;若本地已有完整缓存文件则优先使用。 enum VideoCacheState: Equatable { case notRequired case downloading @@ -76,9 +76,9 @@ enum VideoCacheState: Equatable { case .notRequired, .ready: return nil case .downloading: - return "正在下载完整视频,下载完成后才能播放" + return "正在缓冲视频…" case .failed(let message): - return "视频缓存失败:\(message)" + return "视频加载失败:\(message)" } } } diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 2d16d70..86bbb30 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -31,8 +31,9 @@ final class PlayerController: ObservableObject { private var swipeBackAnchorTime: TimeInterval = 0 private var speedBeforeLongPress: Float? private var transientTask: Task? - private var cacheSwapTask: Task? + private var cacheFallbackTask: Task? private var activePlaybackURL: URL? + private var didAttemptFullFileFallback = false private var sentenceEndTimeObserver: Any? private var sentenceEndGuardToken = 0 @@ -91,8 +92,9 @@ final class PlayerController: ObservableObject { @MainActor func load(_ item: TrainingMediaItem) { cancelSentenceBoundaryGuard() - cacheSwapTask?.cancel() - cacheSwapTask = nil + cacheFallbackTask?.cancel() + cacheFallbackTask = nil + didAttemptFullFileFallback = false lastSentenceIndex = nil swipeBackAnchorIndex = nil pendingSeekPositionMs = nil @@ -110,12 +112,8 @@ final class PlayerController: ObservableObject { cacheState = .ready preparePlaybackItem(with: cachedURL) } else { - // 关键约束:云端视频没有完整本地文件前,不把 remote URL 交给 AVPlayer。 - activePlaybackURL = nil - cacheState = .downloading - player.replaceCurrentItem(with: nil) - tick() - scheduleFullFileDownload(for: item) + cacheState = .notRequired + preparePlaybackItem(with: item.url) } } @@ -480,39 +478,8 @@ final class PlayerController: ObservableObject { 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) { - let newItem = AVPlayerItem(url: playbackURL) + let newItem = makePlayerItem(url: playbackURL) activePlaybackURL = playbackURL attachItemObservers(to: newItem) player.replaceCurrentItem(with: newItem) @@ -524,6 +491,19 @@ final class PlayerController: ObservableObject { 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 必须统一走这里,避免播放循环、 /// 用户手势和 seek 完成回调交错后把位置拉回旧句子。 private func performSeek( @@ -844,12 +824,57 @@ final class PlayerController: ObservableObject { if item.status == .failed { let message = item.error?.localizedDescription ?? "未知错误" self.showTransientMessage("播放失败:\(message)") + } else if item.status == .readyToPlay { + self.considerFullFileFallback(for: item) } 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() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .moviePlayback) diff --git a/ios/OralTrainer/Player/PlayerSurfaceView.swift b/ios/OralTrainer/Player/PlayerSurfaceView.swift index be17a85..37408d2 100644 --- a/ios/OralTrainer/Player/PlayerSurfaceView.swift +++ b/ios/OralTrainer/Player/PlayerSurfaceView.swift @@ -13,15 +13,20 @@ struct PlayerSurfaceView: UIViewRepresentable { func makeUIView(context: Context) -> VideoSurfaceView { let view = VideoSurfaceView() - view.playerLayer.player = controller.player view.controller = controller 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 } func updateUIView(_ uiView: VideoSurfaceView, context: Context) { - uiView.playerLayer.player = controller.player + uiView.controller = controller uiView.onVerticalSwipe = onVerticalSwipe + uiView.attach(player: controller.player) } } @@ -29,13 +34,9 @@ final class VideoSurfaceView: UIView { weak var controller: PlayerController? var onVerticalSwipe: ((Bool) -> Void)? - override class var layerClass: AnyClass { - AVPlayerLayer.self - } + private let videoLayer = AVPlayerLayer() - var playerLayer: AVPlayerLayer { - layer as! AVPlayerLayer - } + var playerLayer: AVPlayerLayer { videoLayer } private let minSwipeDistance: CGFloat = 48 private let minSwipeVelocity: CGFloat = 160 @@ -55,7 +56,11 @@ final class VideoSurfaceView: UIView { override init(frame: CGRect) { super.init(frame: frame) 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)) panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) @@ -82,6 +87,25 @@ final class VideoSurfaceView: UIView { 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() { controller?.handleGesture(.singleTap) } @@ -96,7 +120,6 @@ final class VideoSurfaceView: UIView { case .began: resetSwipeTracking() case .changed: - // 在滑动达到阈值时立即派发,并且同一个手势只允许派发一次。 evaluateSwipe(dx: dx, dy: dy, velocity: velocity) case .ended, .cancelled, .failed: if !didDispatchSwipe { @@ -112,7 +135,6 @@ final class VideoSurfaceView: UIView { guard !didDispatchSwipe else { return } if swipeAxis == nil { - // 只有方向足够明确时锁定轴向,避免轻微斜滑被误判为上下滑。 if abs(dx) > abs(dy) * 1.2 { swipeAxis = .horizontal } else if abs(dy) > abs(dx) * 1.2 { diff --git a/ios/OralTrainer/Services/StreamingVideoCache.swift b/ios/OralTrainer/Services/StreamingVideoCache.swift index 8b7ff75..a941305 100644 --- a/ios/OralTrainer/Services/StreamingVideoCache.swift +++ b/ios/OralTrainer/Services/StreamingVideoCache.swift @@ -1,11 +1,10 @@ import CryptoKit import Foundation -/// iOS 端的持久化整文件视频缓存。 +/// iOS 端的可选整文件视频缓存。 /// -/// AVFoundation 没有公开可持久化的媒体字节缓存 API。这里先把远程视频完整 -/// 下载成原子本地文件;只有文件 -/// 完整落盘后才返回给播放器,避免用部分响应或播放列表创建可播放的 AVPlayerItem。 +/// 播放默认走远程 URL 按需加载(AVPlayer HTTP Range)。若本地已有完整缓存文件, +/// 播放器会优先使用该文件,避免重复拉流。 @MainActor final class StreamingVideoCache { static let shared = StreamingVideoCache() diff --git a/sentence_api/main.py b/sentence_api/main.py index a97d67d..9fe5ea0 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -481,9 +481,13 @@ def create_app( media_path = service_settings.videos_dir / row["stored_filename"] if not media_path.is_file(): 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( media_path, - media_type=row["content_type"], + media_type=media_type, headers={"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600"}, ) diff --git a/sentence_api/processing.py b/sentence_api/processing.py index 652e06e..4082597 100644 --- a/sentence_api/processing.py +++ b/sentence_api/processing.py @@ -1,10 +1,11 @@ +import json import logging import shutil import subprocess import tempfile import uuid from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from .audio_metrics import ( AudioAnalysisError, @@ -85,6 +86,126 @@ def _ensure_webm_cues_front(filepath: Path) -> None: 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: def __init__( self, @@ -110,6 +231,7 @@ class VideoProcessor: work_path: Optional[Path] = None try: _ensure_webm_cues_front(media_path) + _ensure_ios_compatible_media(media_path) if self.transcriber.available: work_path = self.settings.work_dir / f"{video_hash}-{uuid.uuid4().hex}.wav" extract_audio(media_path, work_path)