feat: 第三轮深度完善 — SQLite/JSON/网络/媒体/文本 真实实现
SQLite真实数据库: - SQLiteDatabase: execSQL/insert/insertOrThrow/replace/update/delete/query 用sqlite3 - 修复getVersion/setVersion用PRAGMA user_version - 修复getMaximumSize/getPageSize/setMaximumSize/setPageSize用PRAGMA - SQLiteStatement: execute/simpleQueryForLong/simpleQueryForString/execQuery - SQLiteOpenHelper: onCreate/onUpgrade/onDowngrade回调正确触发 - 修复SQLiteCursor.moveToPosition越界处理 - 修复ContentValues值对提取bug JSON真实实现: - JSONObject: isNull/append/putOnce/putOpt/getNames - JSONArray: 完整put/get/opt/length/toString 网络真实实现: - NetworkInterface: getByName/getNetworkInterfaces/getHardwareAddress 用/sys/class/net+fcntl.ioctl - InterfaceAddress: 新增类 - ConnectivityManager: getActiveNetwork/getAllNetworks 用LinuxNetwork - WifiManager: getScanResults用nmcli, setWifiEnabled用nmcli/rfkill - BluetoothAdapter: getAddress/getName 用bluetoothctl 媒体真实实现: - MediaPlayer: prepare检查文件, getCurrentPosition实时计算, getDuration/getVideoWidth用ffprobe - MediaRecorder: setVideoSize/setVideoFrameRate/setVideoEncodingBitRate配置存储 - MediaMetadataRetriever: extractMetadata用ffprobe, getFrameAtTime用ffmpeg, getEmbeddedPicture用ffmpeg 文本真实实现: - TextUtils: split用re.split, concat/indexOf/lastIndexOf/urlEncode/urlDecode - SpannableString: setSpan/getSpans/getSpanFlags/nextSpanTransition 真实实现 - SpannableStringBuilder: 修复append重载bug Provider增强: - Settings: putBoolean/getBoolean - MediaStore: getContentUri/VOLUME常量 - ContactsContract: 补全常量和CommonDataKinds Util增强: - Log: w/e/wtf支持Throwable参数, 修复wtf重复定义bug - TypedValue: 修复complexToDimension/complexToFloat/getFloat实现 修复: - __init__.py: 补全NetworkInterface/InetAddress/MediaMetadataRetriever/SpannableStringBuilder导出 统计: 221模块, 3666类, 31005方法, 183529行
This commit is contained in:
1 parent
4235f8692c
commit
fbb8263b13
13 files changed
+1373
-146
No files matched your search
+462
-33
@@ -4,8 +4,10 @@ Android Media 组件 — MediaPlayer, MediaRecorder, SoundPool, AudioManager 等
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import struct
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
@@ -21,6 +23,113 @@ except ImportError:
|
||||
_USE_LINUX = False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ffprobe / ffmpeg 辅助函数
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _ffprobe_metadata(path: str) -> Optional[Dict[str, Any]]:
|
||||
"""用 ffprobe 获取媒体文件元数据,返回解析后的 JSON 字典。
|
||||
失败时返回 None。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", path],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (FileNotFoundError, subprocess.SubprocessError,
|
||||
json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _ffprobe_duration_ms(path: str) -> int:
|
||||
"""用 ffprobe 获取时长(毫秒),失败返回 0。"""
|
||||
data = _ffprobe_metadata(path)
|
||||
if not data:
|
||||
return 0
|
||||
try:
|
||||
fmt = data.get("format", {})
|
||||
duration = fmt.get("duration")
|
||||
if duration is not None:
|
||||
return int(float(duration) * 1000)
|
||||
# 回退:从流中找
|
||||
for stream in data.get("streams", []):
|
||||
d = stream.get("duration")
|
||||
if d is not None:
|
||||
return int(float(d) * 1000)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _ffprobe_video_size(path: str) -> Tuple[int, int]:
|
||||
"""用 ffprobe 获取视频分辨率 (width, height),失败返回 (0, 0)。"""
|
||||
data = _ffprobe_metadata(path)
|
||||
if not data:
|
||||
return (0, 0)
|
||||
try:
|
||||
for stream in data.get("streams", []):
|
||||
if stream.get("codec_type") == "video":
|
||||
w = int(stream.get("width", 0))
|
||||
h = int(stream.get("height", 0))
|
||||
if w > 0 and h > 0:
|
||||
return (w, h)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return (0, 0)
|
||||
|
||||
|
||||
def _ffmpeg_extract_frame(path: str, time_us: int = -1,
|
||||
width: int = 0, height: int = 0) -> Optional[Any]:
|
||||
"""用 ffmpeg 提取指定时间点的视频帧,返回 PIL Image。
|
||||
time_us < 0 表示提取第一帧。失败返回 None。"""
|
||||
if not HAS_PIL:
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
cmd = ["ffmpeg", "-v", "quiet"]
|
||||
if time_us is not None and time_us >= 0:
|
||||
cmd += ["-ss", str(time_us / 1_000_000.0)]
|
||||
cmd += ["-i", path, "-frames:v", "1", "-f", "image2pipe",
|
||||
"-vcodec", "png", "-"]
|
||||
if width > 0 and height > 0:
|
||||
# 在解码后缩放
|
||||
cmd = ["ffmpeg", "-v", "quiet"]
|
||||
if time_us is not None and time_us >= 0:
|
||||
cmd += ["-ss", str(time_us / 1_000_000.0)]
|
||||
cmd += ["-i", path, "-frames:v", "1", "-vf",
|
||||
f"scale={width}:{height}", "-f", "image2pipe",
|
||||
"-vcodec", "png", "-"]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
return Image.open(io.BytesIO(result.stdout))
|
||||
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ffmpeg_extract_cover(path: str) -> Optional[bytes]:
|
||||
"""用 ffmpeg 提取嵌入的封面图片,返回原始字节。
|
||||
失败返回 None。"""
|
||||
try:
|
||||
# 尝试提取附加图片流(封面)
|
||||
cmd = ["ffmpeg", "-v", "quiet", "-i", path,
|
||||
"-map", "0:v", "-frames:v", "1", "-f", "image2pipe",
|
||||
"-vcodec", "png", "-"]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout
|
||||
return None
|
||||
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AudioAttributes
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -243,6 +352,7 @@ class MediaPlayer(Stub):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._data_source = None
|
||||
self._resolved_path = None
|
||||
self._prepared = False
|
||||
self._playing = False
|
||||
self._looping = False
|
||||
@@ -257,6 +367,8 @@ class MediaPlayer(Stub):
|
||||
self._audio_attributes = None
|
||||
self._display = None
|
||||
self._surface = None
|
||||
self._released = False
|
||||
self._start_time = 0.0 # 用于模拟播放进度
|
||||
|
||||
# Listeners
|
||||
self._on_prepared_listener = None
|
||||
@@ -267,43 +379,126 @@ class MediaPlayer(Stub):
|
||||
self._on_video_size_changed_listener = None
|
||||
|
||||
def setDataSource(self, path: str = None, uri=None, headers: Dict = None):
|
||||
self._data_source = path or str(uri)
|
||||
self._data_source = path or str(uri) if uri is not None else path
|
||||
if self._data_source:
|
||||
self._resolved_path = resolve_android_path(self._data_source)
|
||||
|
||||
def _load_media_info(self):
|
||||
"""用 ffprobe 加载时长和分辨率信息(try/except 保护)。"""
|
||||
if not self._resolved_path:
|
||||
return
|
||||
if not os.path.exists(self._resolved_path):
|
||||
return
|
||||
try:
|
||||
duration = _ffprobe_duration_ms(self._resolved_path)
|
||||
if duration > 0:
|
||||
self._duration = duration
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
w, h = _ffprobe_video_size(self._resolved_path)
|
||||
if w > 0 and h > 0:
|
||||
self._video_width = w
|
||||
self._video_height = h
|
||||
if self._on_video_size_changed_listener:
|
||||
self._on_video_size_changed_listener.onVideoSizeChanged(
|
||||
self, w, h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def prepare(self):
|
||||
if self._released:
|
||||
return
|
||||
if self._resolved_path and not os.path.exists(self._resolved_path):
|
||||
if self._on_error_listener:
|
||||
self._on_error_listener.onError(
|
||||
self, MediaPlayer.MEDIA_ERROR_UNKNOWN, -1004)
|
||||
raise IOError(f"数据源文件不存在: {self._resolved_path}")
|
||||
self._load_media_info()
|
||||
self._prepared = True
|
||||
|
||||
def prepareAsync(self):
|
||||
if self._released:
|
||||
return
|
||||
if self._resolved_path and not os.path.exists(self._resolved_path):
|
||||
if self._on_error_listener:
|
||||
self._on_error_listener.onError(
|
||||
self, MediaPlayer.MEDIA_ERROR_UNKNOWN, -1004)
|
||||
return
|
||||
self._load_media_info()
|
||||
self._prepared = True
|
||||
if self._on_prepared_listener:
|
||||
self._on_prepared_listener.onPrepared(self)
|
||||
|
||||
def start(self):
|
||||
if self._released:
|
||||
return
|
||||
self._playing = True
|
||||
self._start_time = time.time()
|
||||
|
||||
def stop(self):
|
||||
self._playing = False
|
||||
|
||||
def pause(self):
|
||||
if self._playing:
|
||||
# 记录已播放的进度
|
||||
elapsed = int((time.time() - self._start_time) * 1000)
|
||||
self._current_position = min(
|
||||
self._current_position + elapsed, self._duration)
|
||||
self._playing = False
|
||||
|
||||
def reset(self):
|
||||
self._playing = False
|
||||
self._prepared = False
|
||||
self._current_position = 0
|
||||
self._duration = 0
|
||||
self._video_width = 0
|
||||
self._video_height = 0
|
||||
self._data_source = None
|
||||
self._resolved_path = None
|
||||
|
||||
def release(self):
|
||||
self._playing = False
|
||||
self._prepared = False
|
||||
self._released = True
|
||||
self._data_source = None
|
||||
self._resolved_path = None
|
||||
self._on_prepared_listener = None
|
||||
self._on_completion_listener = None
|
||||
self._on_error_listener = None
|
||||
self._on_info_listener = None
|
||||
self._on_seek_complete_listener = None
|
||||
self._on_video_size_changed_listener = None
|
||||
|
||||
def seekTo(self, msec: int):
|
||||
self._current_position = max(0, min(msec, self._duration))
|
||||
self._start_time = time.time()
|
||||
if self._on_seek_complete_listener:
|
||||
self._on_seek_complete_listener.onSeekComplete(self)
|
||||
|
||||
def getCurrentPosition(self) -> int:
|
||||
if self._playing and self._start_time > 0:
|
||||
elapsed = int((time.time() - self._start_time) * 1000)
|
||||
pos = self._current_position + elapsed
|
||||
if self._duration > 0 and pos >= self._duration:
|
||||
pos = self._duration
|
||||
if self._looping:
|
||||
self._current_position = 0
|
||||
self._start_time = time.time()
|
||||
pos = 0
|
||||
else:
|
||||
self._playing = False
|
||||
if self._on_completion_listener:
|
||||
self._on_completion_listener.onCompletion(self)
|
||||
return pos
|
||||
return self._current_position
|
||||
|
||||
def getDuration(self) -> int:
|
||||
if self._duration == 0 and self._resolved_path:
|
||||
try:
|
||||
self._duration = _ffprobe_duration_ms(self._resolved_path)
|
||||
except Exception:
|
||||
pass
|
||||
return self._duration
|
||||
|
||||
def isPlaying(self) -> bool:
|
||||
@@ -335,9 +530,25 @@ class MediaPlayer(Stub):
|
||||
self._display = surface_holder
|
||||
|
||||
def getVideoWidth(self) -> int:
|
||||
if self._video_width == 0 and self._resolved_path:
|
||||
try:
|
||||
w, h = _ffprobe_video_size(self._resolved_path)
|
||||
if w > 0:
|
||||
self._video_width = w
|
||||
self._video_height = h
|
||||
except Exception:
|
||||
pass
|
||||
return self._video_width
|
||||
|
||||
def getVideoHeight(self) -> int:
|
||||
if self._video_height == 0 and self._resolved_path:
|
||||
try:
|
||||
w, h = _ffprobe_video_size(self._resolved_path)
|
||||
if h > 0:
|
||||
self._video_width = w
|
||||
self._video_height = h
|
||||
except Exception:
|
||||
pass
|
||||
return self._video_height
|
||||
|
||||
def setOnPreparedListener(self, listener: "MediaPlayer.OnPreparedListener"):
|
||||
@@ -434,6 +645,15 @@ class MediaRecorder(Stub):
|
||||
self._output_file = None
|
||||
self._preview_display = None
|
||||
self._recording = False
|
||||
self._prepared = False
|
||||
self._released = False
|
||||
self._video_width = 0
|
||||
self._video_height = 0
|
||||
self._video_frame_rate = 0
|
||||
self._video_encoding_bit_rate = 0
|
||||
self._audio_encoding_bit_rate = 0
|
||||
self._max_duration = 0
|
||||
self._max_filesize = 0
|
||||
|
||||
def setAudioSource(self, audio_source: int):
|
||||
self._audio_source = audio_source
|
||||
@@ -453,13 +673,36 @@ class MediaRecorder(Stub):
|
||||
def setOutputFile(self, path: str):
|
||||
self._output_file = path
|
||||
|
||||
def setVideoSize(self, width: int, height: int):
|
||||
self._video_width = width
|
||||
self._video_height = height
|
||||
|
||||
def setVideoFrameRate(self, rate: int):
|
||||
self._video_frame_rate = rate
|
||||
|
||||
def setVideoEncodingBitRate(self, bit_rate: int):
|
||||
self._video_encoding_bit_rate = bit_rate
|
||||
|
||||
def setAudioEncodingBitRate(self, bit_rate: int):
|
||||
self._audio_encoding_bit_rate = bit_rate
|
||||
|
||||
def setMaxDuration(self, max_duration_ms: int):
|
||||
self._max_duration = max_duration_ms
|
||||
|
||||
def setMaxFileSize(self, max_filesize_bytes: int):
|
||||
self._max_filesize = max_filesize_bytes
|
||||
|
||||
def setPreviewDisplay(self, surface_holder):
|
||||
self._preview_display = surface_holder
|
||||
|
||||
def prepare(self):
|
||||
pass
|
||||
if self._released:
|
||||
return
|
||||
self._prepared = True
|
||||
|
||||
def start(self):
|
||||
if self._released or not self._prepared:
|
||||
return
|
||||
self._recording = True
|
||||
|
||||
def stop(self):
|
||||
@@ -467,11 +710,29 @@ class MediaRecorder(Stub):
|
||||
|
||||
def reset(self):
|
||||
self._recording = False
|
||||
self._prepared = False
|
||||
self._audio_source = 0
|
||||
self._video_source = 0
|
||||
self._output_format = 0
|
||||
self._audio_encoder = 0
|
||||
self._video_encoder = 0
|
||||
self._output_file = None
|
||||
self._video_width = 0
|
||||
self._video_height = 0
|
||||
self._video_frame_rate = 0
|
||||
self._video_encoding_bit_rate = 0
|
||||
|
||||
def release(self):
|
||||
self._recording = False
|
||||
self._prepared = False
|
||||
self._released = True
|
||||
self._output_file = None
|
||||
self._preview_display = None
|
||||
|
||||
def getMaxAmplitude(self) -> int:
|
||||
if self._recording:
|
||||
import random
|
||||
return random.randint(0, 32767)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1025,53 +1286,221 @@ class MediaMetadataRetriever(Stub):
|
||||
METADATA_KEY_BITRATE = "bitrate"
|
||||
METADATA_KEY_LOCATION = "location"
|
||||
|
||||
# 提取帧选项
|
||||
OPTION_CLOSEST = 0x01
|
||||
OPTION_CLOSEST_SYNC = 0x02
|
||||
OPTION_NEXT_SYNC = 0x03
|
||||
OPTION_PREVIOUS_SYNC = 0x04
|
||||
|
||||
# ffprobe format_name 到 MIME 类型映射
|
||||
_MIME_MAP = {
|
||||
"mov,mp4,m4a,3gp,3g2,mj2": "video/mp4",
|
||||
"matroska,webm": "video/x-matroska",
|
||||
"avi": "video/x-msvideo",
|
||||
"flv": "video/x-flv",
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"aac": "audio/aac",
|
||||
"ogg": "audio/ogg",
|
||||
"flac": "audio/flac",
|
||||
"asf": "video/x-ms-asf",
|
||||
"mpeg": "video/mpeg",
|
||||
"webm": "video/webm",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._metadata: Dict[str, str] = {}
|
||||
self._resolved_path: Optional[str] = None
|
||||
self._ffprobe_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
def setDataSource(self, path: str):
|
||||
resolved = resolve_android_path(path)
|
||||
if os.path.exists(resolved):
|
||||
size = os.path.getsize(resolved)
|
||||
self._metadata = {
|
||||
MediaMetadataRetriever.METADATA_KEY_DURATION: "30000",
|
||||
MediaMetadataRetriever.METADATA_KEY_TITLE: os.path.basename(resolved),
|
||||
MediaMetadataRetriever.METADATA_KEY_MIMETYPE: "video/mp4",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH: "1920",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT: "1080",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO: "yes",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO: "yes",
|
||||
MediaMetadataRetriever.METADATA_KEY_BITRATE: str(size * 8 // 30),
|
||||
}
|
||||
def setDataSource(self, path: str, headers: Optional[Dict[str, str]] = None):
|
||||
"""设置数据源并用 ffprobe 加载元数据。"""
|
||||
self._resolved_path = resolve_android_path(path)
|
||||
self._metadata = {}
|
||||
self._ffprobe_data = None
|
||||
if not self._resolved_path or not os.path.exists(self._resolved_path):
|
||||
return
|
||||
# 用 ffprobe 获取真实元数据(try/except 保护)
|
||||
try:
|
||||
self._ffprobe_data = _ffprobe_metadata(self._resolved_path)
|
||||
except Exception:
|
||||
self._ffprobe_data = None
|
||||
if self._ffprobe_data:
|
||||
self._parse_ffprobe_metadata()
|
||||
|
||||
def setDataSource(self, path: str, headers: Dict[str, str]):
|
||||
self.setDataSource(path)
|
||||
def _parse_ffprobe_metadata(self):
|
||||
"""将 ffprobe 输出解析为 Android 风格的元数据键值对。"""
|
||||
if not self._ffprobe_data:
|
||||
return
|
||||
data = self._ffprobe_data
|
||||
fmt = data.get("format", {})
|
||||
tags = fmt.get("tags", {})
|
||||
streams = data.get("streams", [])
|
||||
|
||||
# 找到视频流和音频流
|
||||
video_stream = None
|
||||
audio_stream = None
|
||||
for s in streams:
|
||||
ct = s.get("codec_type", "")
|
||||
if ct == "video" and video_stream is None:
|
||||
video_stream = s
|
||||
elif ct == "audio" and audio_stream is None:
|
||||
audio_stream = s
|
||||
|
||||
md = self._metadata
|
||||
|
||||
# 时长(毫秒)
|
||||
duration = fmt.get("duration")
|
||||
if duration is None and video_stream:
|
||||
duration = video_stream.get("duration")
|
||||
if duration is None and audio_stream:
|
||||
duration = audio_stream.get("duration")
|
||||
if duration is not None:
|
||||
try:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_DURATION] = str(
|
||||
int(float(duration) * 1000))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 标题
|
||||
title = tags.get("title") or tags.get("TITLE")
|
||||
if title:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_TITLE] = title
|
||||
|
||||
# 艺术家
|
||||
artist = tags.get("artist") or tags.get("ARTIST")
|
||||
if artist:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_ARTIST] = artist
|
||||
|
||||
# 专辑
|
||||
album = tags.get("album") or tags.get("ALBUM")
|
||||
if album:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_ALBUM] = album
|
||||
|
||||
# 作曲家
|
||||
composer = tags.get("composer") or tags.get("COMPOSER")
|
||||
if composer:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_COMPOSER] = composer
|
||||
|
||||
# 作者
|
||||
writer = tags.get("writer") or tags.get("WRITER")
|
||||
if writer:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_WRITER] = writer
|
||||
|
||||
# 流派
|
||||
genre = tags.get("genre") or tags.get("GENRE")
|
||||
if genre:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_GENRE] = genre
|
||||
|
||||
# 日期 / 年份
|
||||
date = tags.get("date") or tags.get("DATE") or tags.get("year")
|
||||
if date:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_DATE] = str(date)
|
||||
# 尝试提取年份
|
||||
year_str = str(date)[:4]
|
||||
if year_str.isdigit():
|
||||
md[MediaMetadataRetriever.METADATA_KEY_YEAR] = year_str
|
||||
|
||||
# 音轨号
|
||||
track = tags.get("track") or tags.get("TRACK")
|
||||
if track:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER] = str(track)
|
||||
|
||||
# 位置
|
||||
location = tags.get("location") or tags.get("LOCATION")
|
||||
if location:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_LOCATION] = str(location)
|
||||
|
||||
# MIME 类型
|
||||
format_name = fmt.get("format_name", "")
|
||||
if format_name:
|
||||
mime = MediaMetadataRetriever._MIME_MAP.get(
|
||||
format_name, f"video/{format_name.split(',')[0]}")
|
||||
md[MediaMetadataRetriever.METADATA_KEY_MIMETYPE] = mime
|
||||
|
||||
# 轨道数
|
||||
if streams:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS] = str(len(streams))
|
||||
|
||||
# 比特率
|
||||
bit_rate = fmt.get("bit_rate")
|
||||
if bit_rate:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_BITRATE] = str(bit_rate)
|
||||
|
||||
# 视频信息
|
||||
if video_stream:
|
||||
w = video_stream.get("width", 0)
|
||||
h = video_stream.get("height", 0)
|
||||
if w:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH] = str(w)
|
||||
if h:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT] = str(h)
|
||||
md[MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO] = "yes"
|
||||
else:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO] = "no"
|
||||
|
||||
# 音频信息
|
||||
if audio_stream:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO] = "yes"
|
||||
else:
|
||||
md[MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO] = "no"
|
||||
|
||||
def extractMetadata(self, keyCode: str) -> Optional[str]:
|
||||
return self._metadata.get(keyCode)
|
||||
|
||||
def getEmbeddedPicture(self) -> Optional[bytes]:
|
||||
"""用 ffmpeg 提取嵌入的封面图片,返回原始字节数据。
|
||||
失败返回 None。"""
|
||||
if not self._resolved_path or not os.path.exists(self._resolved_path):
|
||||
return None
|
||||
try:
|
||||
return _ffmpeg_extract_cover(self._resolved_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def getFrameAtTime(self, timeUs: int = -1, option: int = 0) -> Optional["Bitmap"]:
|
||||
if HAS_PIL:
|
||||
from PIL import Image
|
||||
from .graphics import Bitmap
|
||||
img = Image.new("RGB", (1920, 1080), (64, 64, 64))
|
||||
bm = Bitmap(1920, 1080, Bitmap.Config.ARGB_8888)
|
||||
bm._image = img
|
||||
return bm
|
||||
"""用 ffmpeg 提取指定时间点的视频帧,返回 Bitmap(内含 PIL Image)。
|
||||
timeUs < 0 表示提取第一帧。失败返回 None。"""
|
||||
if not self._resolved_path or not os.path.exists(self._resolved_path):
|
||||
return None
|
||||
try:
|
||||
img = _ffmpeg_extract_frame(self._resolved_path, timeUs)
|
||||
if img is not None:
|
||||
from .graphics import Bitmap
|
||||
w, h = img.size
|
||||
bm = Bitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
# 确保 RGBA 模式
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
bm._image = img
|
||||
return bm
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def getScaledFrameAtTime(self, timeUs: int, option: int, width: int, height: int) -> Optional["Bitmap"]:
|
||||
if HAS_PIL:
|
||||
from PIL import Image
|
||||
from .graphics import Bitmap
|
||||
img = Image.new("RGB", (width, height), (64, 64, 64))
|
||||
bm = Bitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bm._image = img
|
||||
return bm
|
||||
def getScaledFrameAtTime(self, timeUs: int, option: int,
|
||||
width: int, height: int) -> Optional["Bitmap"]:
|
||||
"""用 ffmpeg 提取并缩放指定时间点的视频帧。"""
|
||||
if not self._resolved_path or not os.path.exists(self._resolved_path):
|
||||
return None
|
||||
try:
|
||||
img = _ffmpeg_extract_frame(self._resolved_path, timeUs, width, height)
|
||||
if img is not None:
|
||||
from .graphics import Bitmap
|
||||
bm = Bitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
bm._image = img
|
||||
return bm
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def release(self):
|
||||
self._metadata.clear()
|
||||
self._resolved_path = None
|
||||
self._ffprobe_data = None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in new issue
Block a user