阅读视图

发现新文章,点击刷新页面。

react native中实现视频转歌

先放图

微信图片_20260114172105_165_23.jpg

微信图片_20260114172111_166_23.jpg

微信图片_20260114172102_162_23.jpg

微信图片_20260114172103_163_23.jpg

前言

先解释一下视频转歌:它的本质是提取视频文件的音频部分为独立文件,底层技术原理是不是这样我也不是特别清楚。提取后存到设备内存中 ,音乐播放器可以读取播放该文件。该功能是我在某个音乐播放器应用上看到功能菜单有一个视频转歌功能,并简单尝试了,它的效果就是将选择的视频转换为音频,不过该软件转换后的文件是.vts格式,想要使用转换产物肯定要支持.vts或者了解这种文件怎么读取,至于为什么是这样大家应该都能理解,这也不是今天的重点。

项目环境关键依赖

在rn应用中,依赖版本对应性很重要,一旦有一点不匹配就会各种问题,各种失败,而且是使用expo创建的rn项目在需要原生功能时安装的依赖通常需要开发构建,下面是关键依赖以及版本:

  • "expo": "~54.0.31",
  • "kroog-ffmpeg-kit-react-native": "^6.0.9",
  • "react": "19.1.0",

实现视频转歌的核心依赖是kroog-ffmpeg-kit-react-native,这个依赖很明显是folk的分支,原包ffmpeg-kit好像已经不再维护,前段时间有个新闻说谷歌AI发现该依赖的漏洞,一段时间如果不修复,谷歌会公布漏洞,该项目团队回应说(通俗地说)你要是有良心就捐点money,你也是大用户,带头白嫖巴拉巴的。

核心依赖找到后,除了如何使用依赖外,流程非常清晰:选择视频文件->获取uri等信息->交给ffmpeg->转化->监听处理转换进度 ->更新进度->获取转换后的缓存结果->保存到设备:

1.选取视频文件

选取视频可以使用两种依赖库,分别是expo-media-library和expo-document-picker, 这里我们使用前者获取设备中MediaType.video类型的数据渲染成列表,供用户选择,选取后我们拿到uri

import { getAssetsAsync, MediaType, requestPermissionsAsync, type AssetsOptions } from 'expo-media-library';
    /**
     * 请求权限并获取视频列表
     *
     * 此方法会先请求媒体库读取权限,如果授权成功则调用 fetchVideos 方法加载视频数据。
     * 如果未获得权限,则弹出提示框告知用户。
     *
     * @param reset 是否重置当前已有的视频列表,默认为 false
     * @returns Promise<void>
     */
    const requestPermissionAndGetVideos = async (reset = true) => {
        try {
            setLoading(true);
            // 请求媒体库权限
            const { status } = await requestPermissionsAsync();
            if (status !== 'granted') {
                showNotification({ tip: '需要访问媒体库权限才能获取视频文件,请在设置中开启权限。', type: 'warning' });
                setHasPermission(false);
                return;
            }
            setHasPermission(true);
            // 获取视频文件,重新请求权限时默认重置列表
            await fetchVideos(reset);
        } catch (error) {
            showNotification({ tip: '获取视频文件失败,请重试', type: 'error' });
        } finally {
            setLoading(false);
        }
    };
        /**
         * 使用expo-media-library 获取视频文件列表
         * 和expo-document-picker 获取视频文件列表结果不太一样,expo-media-library 会返回更多的视频文件
         * 调用系统 API 获取指定范围内的视频资源,并更新状态以渲染到界面。
         * 支持分页加载与刷新功能。
         *
         * @param reset 是否清空已有数据后重新加载,默认为 false
         * @returns Promise<void>
         */
    const fetchVideos = async (reset = false) => {
        try {
            const options: AssetsOptions = {
                mediaType: MediaType.video, // 只获取视频文件
                first, // 每次加载根据列数动态调整
                after: reset ? undefined : after, // 分页加载
                sortBy: [['modificationTime', true]], // 按修改时间降序排序
            };
            const { assets = [], endCursor, hasNextPage } = await getAssetsAsync(options);
            const videoList = assets.map(asset => ({
                ...asset,
                durationString: formatTime(asset.duration),
            }));
            if (reset) {
                setVideos(videoList);
            } else {
                setVideos(prev => [...prev, ...videoList]);
            }
            setAfter(endCursor);
            setHasMore(hasNextPage);
        } catch (error) {
            showNotification({ tip: '获取视频列表失败,请重试', type: 'error' });
        }
    };

通过以上代码我们拿到视频列表,使用expo-media-library的好处在于可以分批获取视频文件,非常便于分页加载

2.核心操作 使用ffmpeg-kit从指定文件中提取音频: 当用户选取视频后我们存储uri,并使用expo-file-system检查文件uri是否存在,expo-file-system目前有新版和legacy版本,API区别很大,新版有三个核心依赖:Paths File Directory,API不像老版本直观,目前可以使用legacy版本,根据自己情况选择即可 文件存在则将文件存入缓存目录,使用FFmpegKi类执行命令: -i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"; 目前该依赖库并不能转换成mp3但是可以转换成m4a格式音频:

import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';

    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 转换命令参考:
             * 
            */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入musicList文件夹下的convertList.json文件中
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };

completeCallback中是转换完成时执行的回调函数,而legacyLogCallback回调函数则是转换过程的日志回调函数 ,在该函数中可以做转换进度更新,也就是说我们核心就是使用调用conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback); 并且在转换过程中添加取消转换和转换失败处理:基本效果就是如下:

微信图片_20260114164854_149_23.jpg

微信图片_20260114164855_150_23.jpg

3.取消转换

转换途中万一发现不是自己想要转换的,可以终止转换,我们要清理转换前创建的文件等:

    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // mark as cancelled so callbacks skip saving
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };

4.转换完成可以提示用户修改歌曲信息:

    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }

微信图片_20260114164856_151_23.jpg

微信图片_20260114164857_152_23.jpg

用户可以确定和取消,都将保存转换后的文件

5.查看转换后的歌曲列表,在这里可以修改和删除:

微信图片_20260114164859_154_23.jpg

微信图片_20260114164900_155_23.jpg

微信图片_20260114164901_156_23.jpg

当前还可以播放:

微信图片_20260114170201_157_23.jpg

关于音乐播放,当前项目中使用了react-native-audio-pro和expo-audio,在转换管理界面的播放使用了exp-audio,其实目前rn音乐播放器使用react-native-track-player依赖库最好,但是对于新版本rn和expo有难以解决的bug,react-native-audio-pro则bug少一点

6.创建音频文件的封面图

核心使用expo-video-thumbnails依赖库,获取指定时间的图像,并设置图片质量,但是格式就只能是jpg

import { getThumbnailAsync, type VideoThumbnailsOptions } from 'expo-video-thumbnails';
/**
 * 为音频添加封面
 * 使用expo-video-thumbnails库生成音频封面
 * 将图片保存到应用私有目录的cover文件夹中,图片格式.jpg,默认最低画质
 * @param audioUri 音频uri
 * @param coverName 封面名称 通常与音频文件名相同
 * @returns 封面uri
 */
export const addAudioCover = async (audioUri: string, coverName: string, seconds: number = 2000): Promise<string | null> => {
    if (!coverName.trim()) {
        return null;
    }
    const { exists } = Paths.info(audioUri);
    if (!exists) {
        return null;
    };
    try {
        const obj: VideoThumbnailsOptions = {
            time: seconds,
            quality: .7,//0~1,0最低1最高
        };
        /**
         * 获取视频文件指定秒数的音频封面
         */
        const { uri } = await getThumbnailAsync(audioUri, obj);
        /**
         * 获取扩展名便于生成封面uri
         */
        const ext = uri.split('.').at(-1);
        const file = new File(uri);
        /**
         * 创建封面目录
         * 如果目录不存在则创建
         */
        const coverUrl = `${Paths.document.uri + COVER_FILE_FILE_NAME}/`
        const { exists, isDirectory } = Paths.info(coverUrl);
        if (!exists || !isDirectory) {
            new Directory(Paths.document, 'cover').create({ intermediates: true });
        };
        /**
         * 创建封面文件
         * 并copy到封面目录
         */
        const finnalUri = `${coverUrl + coverName}.${ext}`;
        /**
         *  检测是否重名
         */
        const info = Paths.info(finnalUri);
        if (info.exists) {
            return null;
        }
        file.copy(new File(finnalUri));
        file.delete();
        return finnalUri;
    } catch (error) {
        return null
    }
}

7.使用FFmpegKit获取已有mp3文件的封面图,并不是所有文件都有

import { FFmpegKit } from 'kroog-ffmpeg-kit-react-native';
/**
 * 使用FFmpeg提取MP3音频文件的封面图片
 * @param audioUri 音频文件的URI路径
 * @param coverName 封面图片的名称(不带扩展名)
 * @returns 封面图片的完整URI路径,如果提取失败返回null
 */
export const extractMp3Cover = async (audioUri: string, coverName: string): Promise<string | null> => {
    if (!audioUri || !coverName.trim()) {
        return null;
    }

    try {
        // 创建cover目录
        const coverDir = new Directory(Paths.document, 'cover');
        if (!coverDir.exists) {
            coverDir.create({ intermediates: true });
        }

        // 构建输出路径
        const coverPath = `${Paths.document.uri}cover/${coverName}.jpg`;
        const coverFile = new File(coverPath);

        // 检查封面是否已存在
        if (coverFile.exists) {
            return coverPath;
        }

        // 使用FFmpeg提取封面图片
        // -an: 不处理音频
        // -vcodec copy: 直接复制视频流(封面)
        // -y: 覆盖已存在的文件
        const command = `-i "${audioUri}" -an -vcodec copy -y "${coverPath}"`;
        const session = await FFmpegKit.execute(command);
        const returnCode = await session.getReturnCode();

        if (returnCode.isValueSuccess()) {
            // 检查文件是否真的创建成功
            if (coverFile.exists) {
                console.log(`成功提取封面: ${coverName}`);
                return coverPath;
            } else {
                // 如果文件没有创建,说明没有嵌入封面
                return null;
            }
        } else {
            const output = await session.getOutput();
            console.warn('提取封面失败:', output);
            return null;
        }
    } catch (error) {
        console.error('提取MP3封面出错:', error);
        return null;
    }
};

8.完整代码:

import { useRef, useState, useEffect, type FC } from 'react';
import TitleBar from '@/components/music/TitleBar';
import VideoList from '@/components/VideoList';
import { Ionicons } from '@expo/vector-icons';
import type { VideoAsset } from '@/types';
import { router } from 'expo-router';
import { FFmpegKit, type FFmpegSession, type Log, type FFmpegSessionCompleteCallback, type LogCallback } from 'kroog-ffmpeg-kit-react-native';
import { Modal, StyleSheet, View, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ThemedView, ThemedText } from '@/components/theme/ThemedComponents';
import { useThemeNotification } from '@/hooks/useTheme';
import { File, Paths, Directory, type DirectoryCreateOptions } from 'expo-file-system';
import { addAudioCover } from '@/utils/audioManager';
import type { SqliteSongInfo, UpdateSongInfoConfig } from '@/types';
import UpdateOrDeleteModal from '@/components/music/localComp/UpdateOrDeleteModal';
import ProcessTip from '@/components/ui/ProcessTip';
import { addSong } from '@/libs/sqlite';
import { SQLITE_CONVERTED_TABLE_NAME, MUSIC_FILE_FILE_NAME } from '@/constants/SongList';
/**
 * 视频转歌曲页面
 * @returns 
 */
const FfmpegKitPage: FC = () => {
    const { top, bottom } = useSafeAreaInsets();
    const [isConverting, setIsConverting] = useState<boolean>(false);
    const [progress, setProgress] = useState(0);
    /**是否转换成功 */
    const isSuccessRef = useRef<boolean>(false);
    /**保存需要转换的视频文件 */
    const convertVideoRef = useRef<VideoAsset | null>(null);
    /**保存转换后的文件名 */
    const outFileNameRef = useRef<string | null>(null);
    const cachedInputUriRef = useRef<string | null>(null);
    // 添加状态管理
    const cachedOutputUriRef = useRef<string | null>(null);
    const lastOutputUriRef = useRef<string | null>(null);
    const conversionSessionRef = useRef<FFmpegSession | null>(null);
    const cancelledRef = useRef<boolean>(false);
    const showNotification = useThemeNotification();
    const [visible, setVisible] = useState<boolean>(false);
    const handleBack = () => {
        router.dismiss();
    };
    const cleanUp = async () => {
        if (cachedOutputUriRef.current) {
            const file = new File(cachedOutputUriRef.current);
            if (file.exists) {
                file.delete();
            }
        }
        cachedOutputUriRef.current &&= null;
        conversionSessionRef.current &&= null;
    };
    useEffect(() => {
        return () => {
            cleanUp();
        }
    }, [])
    /**
     * 保存转换后的文件
     * 将其从转换完成中提取出来,添加转换后可以填写信息,不填写则使用默认信息
     */
    const saveConvertFile = async (info?: UpdateSongInfoConfig) => {
        if (!convertVideoRef.current || !isSuccessRef.current) {
            return;
        };
        // 复制文件到应用私有目录
        try {
            const { uri, duration, durationString } = convertVideoRef.current;
            const file = new File(cachedOutputUriRef.current!);
            const privateFile = new File(lastOutputUriRef.current!);
            file.copy(privateFile);
            /**创建对应视频的封面图 */
            const coverUrl = await addAudioCover(uri, outFileNameRef.current!) ?? '';
            /**
             * 转换后的音频文件存在
             * 执行添加音频json文件操作
             */
            const { exists, creationTime, md5, uri: musicUri, modificationTime } = privateFile;
            if (exists) {
                /**
                 * 这里必须手动解构,无法直接...rest,File对象上的属性不可枚举
                 * 类的内部是get方法写的
                 * 调整为存储到数据表
                 */
                const { title = outFileNameRef.current!, artist = '未知歌手', album = '未知专辑' } = info ?? {};
                const time = Date.now()
                const obj: SqliteSongInfo = {
                    id: md5 || `${time}`,
                    uri: musicUri,
                    artist,
                    title,
                    duration: duration,
                    durationString,
                    modificationTime: modificationTime ?? time,
                    coverUrl,
                    isCollection: 0,
                    isPrivate: 1,
                    creationTime: creationTime ?? time,
                    album,
                    lyrics: '',
                };
                const bool = await addSong(obj, SQLITE_CONVERTED_TABLE_NAME);
                if (bool) {
                    convertVideoRef.current = null;
                    isSuccessRef.current = false;
                    const cacheInputFile = new File(cachedInputUriRef.current!);
                    if (cacheInputFile.exists) {
                        cacheInputFile.delete();
                        cachedInputUriRef.current = null;
                    }
                    /**删除转换后的缓存音频文件 */
                    file.delete();
                    showNotification({ tip: '音频文件已成功保存', type: 'success' });
                };
            };
        } catch (copyError) {
            showNotification({ tip: '无法保存音频文件到应用文件夹', type: 'error' });
        }
    }
    /**
     * 异步处理视频转音频的核心逻辑函数。
     * 
     * 主要功能包括:
     * 1. 检查并创建用于 FFmpeg 的本地工作目录;
     * 2. 将选中的视频文件复制到缓存目录以确保访问权限;
     * 3. 使用 FFmpeg 提取音频流并进行格式转换;
     * 4. 在转换过程中更新进度条;
     * 5. 完成后将结果保存至应用私有目录,并提示用户;
     * 6. 包含详细的错误处理与取消机制支持。
     *
     * @param video - 视频资源对象,必须包含 uri 和其他必要元数据(如 duration、filename 等)。
     * @returns 无返回值。副作用包括 UI 更新、文件操作及可能的弹窗提示。
     */
    const handleConvertVideo = async (video: VideoAsset) => {
        /**
         * 检查当前是否有选中的视频文件,没有则提示用户选择视频文件
         */
        if (!video) {
            showNotification({ tip: '请选择视频文件', type: 'warning' });
            return;
        };
        /**保存视频资源对象 */
        convertVideoRef.current = video;
        /**
         * 先检查源文件是否存在,源文件不存在停止执行
         * 后续会检查缓存文件是否存在,
         * 如果存在就不缓存,直接使用缓存文件
         */
        const sourceFile = new File(video.uri);
        if (!sourceFile.exists) {
            showNotification({ tip: '视频文件不存在', type: 'error' });
            return;
        }
        const { document, cache } = Paths;
        /**
         *  存储转换后的音频文件用的FFmpeg 的本地工作目录
         */
        let ffmpegFolder: Directory | null = null;
        /**
         * 检测目录是否存在,不存在则创建
         */
        try {

            /**
             * 新版expo-file-system 检测文件是否是文件夹使用
             * Paths.info(uri)静态方法方法可以检测文件是否是文件夹
             * 返回对象{exists: boolean, isDirectory: boolean}
             * 或者直接使用new Dictory(ffmpegDir)返回对象,目录是否存在,不存在调用返回值create方法创建
             * 如果路径中包含中文文字必须使用encodeURI进行编码,否则报错
             * 使用encodeURI可以完整路径转码,使用encodeURIComponent
             * 可能转换过多导致路径报错,主要是路径中的中文名部分要转换
             * 第二个参数文件夹名前后写不写/都可以
             */

            ffmpegFolder = new Directory(Paths.document, MUSIC_FILE_FILE_NAME);
            if (!ffmpegFolder.exists) {
                let obj: DirectoryCreateOptions = { intermediates: true };
                /**
                 * 创建目录
                 */
                ffmpegFolder.create(obj)
            };
        } catch (error) {
            showNotification({ tip: '无法创建存储文件夹', type: 'error' });
            return;
        };
        /**
         * 开始转换视频为音频
         * 1. 复制视频到缓存目录
         * 2. 构建FFmpeg命令
         * 3. 执行转换
         * 4. 清理临时文件
         * 设置开始转换状态为true,重置进度值为0,并设置取消标志为false
         */
        setIsConverting(true);
        setProgress(0);
        cancelledRef.current = false;
        try {
            const { filename } = video;
            const baseName = filename ? filename.replace(/\.[^/.]+$/, '') : `video_${Date.now()}`;
            /**先将目标文件复制到缓存目录中 */
            const cacheDir = cache.uri ?? document.uri ?? '/';
            //方式1: 手动拼接文件要保存到缓存目录路径
            const cachedInputUri = `${cacheDir}${filename ?? baseName}`;
            cachedInputUriRef.current = cachedInputUri;
            // 方式2: 使用File构造函数链式创建目标文件
            // const cachedInputUri = new File(Paths.cache, filename ?? baseName).uri;
            try {
                /**
                 * 创建缓存目标文件对象检查是否存在
                 * 如果缓存文件存在,直接使用缓存文件,不存在则缓存视频文件
                 */
                const targetFile = new File(cachedInputUri);
                if (!targetFile.exists) {
                    sourceFile.copy(targetFile);
                };
            } catch (error) {
                showNotification({ tip: '复制视频到缓存目录失败', type: 'error' });
            };

            // 添加时间戳到文件名,避免文件名冲突
            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
            /**
             * 输出文件名,不带extension,
             * 因为要生成图片时使用名称作为文件名,所以不能带扩展名
             * cachedOutputUri: 缓存输出文件路径
             * finalOutputUri: 最终输出文件路径
             */
            const outputFileName = `${baseName}_${timestamp}`;
            const cachedOutputUri = `${cacheDir}${outputFileName}.m4a`;
            const finalOutputUri = `${ffmpegFolder.uri}${outputFileName}.m4a`;
            outFileNameRef.current = outputFileName;
            // 保存临时文件路径用于清理
            cachedOutputUriRef.current = cachedOutputUri;
            lastOutputUriRef.current = finalOutputUri;
            /**
             * 创建进出路径,用于command命令拼接
             */
            const nativeInputPath = cachedInputUri.startsWith('file://') ? cachedInputUri.replace('file://', '') : cachedInputUri;
            const nativeOutputPath = cachedOutputUri.startsWith('file://') ? cachedOutputUri.replace('file://', '') : cachedOutputUri;
            /** 
             * 构建命令 暂不支持mp3,暂时使用m4a
             * 
             */
            const command = `-i "${nativeInputPath}" -vn -c:a aac -b:a 192k "${nativeOutputPath}"`;
            /**
             * 转换完成回调函数
             * 1.获取转换码
             * 2.检查转换是否成功
             * 3.如果成功,检查是否取消,取消不保存文件
             * 4.如果未取消,保存输出文件
             * 5.清理临时文件
             * 6.创建视频缩略图保存到cover目录中
             * 7.将歌曲信息和图片信息写入SQlite数据库表
             * @param session FFmpegSession
             * @returns 
             */
            const completeCallback: FFmpegSessionCompleteCallback = async (session: FFmpegSession) => {
                try {
                    // 获取转换码
                    const returnCode = await session.getReturnCode();
                    /**
                     * 检查是否成功
                     * 方法1:调用ReturnCode的成员方法isValueSuccess返回布尔值
                     * 方法2:调用ReturnCode的静态方法isSuccess传递ReturnCode对象实例然后返回
                     * 布尔值
                     * 成员方法getCode 返回ReturnCode对象实例的code属性值,0 是success 255 是cancel
                     */
                    /**方式1 调用成员方法检测 */
                    const success = returnCode.isValueSuccess();
                    /**方式2 调用成员方法getValue获取执行值,再调用静态方法isSuccess 检查是否成功 */
                    // success = ReturnCode.isSuccess(returnCode);
                    if (success) {
                        // 如果转换已取消,则跳过保存
                        if (cancelledRef.current) {
                            // 即使取消也要清理临时文件
                            try {
                                if (cachedInputUri) {
                                    new File(cachedInputUri).delete();
                                }
                            } catch (error) {
                                showNotification({ tip: '清理输入缓存文件失败', type: 'error' });
                            }
                            return;
                        };
                        // 检查输出文件是否存在且非空
                        try {
                            const { exists } = Paths.info(cachedOutputUriRef.current!);
                            if (!exists) {
                                showNotification({ tip: '生成的音频文件为空或不存在', type: 'error' });
                                return;
                            };
                        } catch (error) {
                            showNotification({ tip: '无法验证输出文件', type: 'error' });
                            return;
                        };
                        isSuccessRef.current = true;
                    } else {
                        /**
                         * 转换失败后获取状态码解释原因
                         */
                        const returnCodeValue = returnCode.getValue();
                        const config: Record<number, string> = {
                            255: '转换已取消',
                            1: '输入文件不存在或无法读取',
                            2: '输出路径无效或无写入权限',
                        }
                        let errorMessage = config[returnCodeValue] ?? `转换失败 (错误码: ${returnCodeValue})`;
                        showNotification({ tip: errorMessage, type: 'error' });
                    }
                } catch (e) {
                    showNotification({ tip: '转换过程中发生错误', type: 'error' });
                } finally {
                    cancelledRef.current = false;
                    setIsConverting(false);
                    setProgress(0);
                    if (isSuccessRef.current) {
                        setVisible(true);
                    }
                }
            };
            /**
             * 转换过程中日志处理,更新转换进度
             * 使用生产环境友好的日志回调
             * @param log Log 日志对象
             */
            // 为了兼容现有的日志解析逻辑,保留原有的进度计算
            const legacyLogCallback: LogCallback = (log: Log) => {
                const message = log.getMessage();
                if (message.includes('time=')) {
                    try {
                        const timeMatch = message.match(/time=(\d+):(\d+):(\d+\.\d+)/);
                        if (timeMatch) {
                            const hours = parseFloat(timeMatch[1]);
                            const minutes = parseFloat(timeMatch[2]);
                            const seconds = parseFloat(timeMatch[3]);
                            const currentTimeInSeconds = hours * 3600 + minutes * 60 + seconds;
                            const totalTimeInSeconds = (video && (video.duration ?? 0)) || 120;
                            const calculatedProgress = Math.min(100, (currentTimeInSeconds / totalTimeInSeconds) * 100);
                            setProgress(calculatedProgress);
                        }
                    } catch (error) {
                        if (__DEV__) {
                            showNotification({ tip: '进度解析失败', type: 'error' });
                        }
                    }
                }
            };
            /** 开始转换 */
            try {
                /**
                 * 存在如下方法
                 * FFmpegKit.execute(cmd): Promise<FFmpegSession>
                 * executeAsync(command: string, completeCallback?: FFmpegSessionCompleteCallback, logCallback?: LogCallback, statisticsCallback?: StatisticsCallback): Promise<FFmpegSession>
                 */
                conversionSessionRef.current = await FFmpegKit.executeAsync(command, completeCallback, legacyLogCallback);
            } catch (error) {
                showNotification({ tip: '转换过程中发生错误', type: 'error' });
            }
        } catch (e) {
            showNotification({ tip: '转换过程中发生错误', type: 'error' });
        } finally {
            setProgress(0);
        }
    };
    const handleConvertManage = () => {
        router.push('/ConvertManage');
    };
    /**
     * 取消转换
     * @returns Promise<void>
     */
    const handleCancel = async () => {
        // 修改标记
        cancelledRef.current = true;
        if (!conversionSessionRef.current) {
            setIsConverting(false);
            setProgress(0);
            return;
        };

        try {
            /**
             * 优先尝试 session.cancel()
             */
            await conversionSessionRef.current.cancel();
        } catch (error) {
            /**
             * 调用cancel方法失败,尝试通过 sessionId 使用 FFmpegKit.cancel
             */
            try {
                const sessionId = conversionSessionRef.current.getSessionId();
                await FFmpegKit.cancel(sessionId)
                showNotification({ tip: '已取消转换', type: 'success' });
            } catch (err) {
                showNotification({ tip: '取消转换失败', type: 'error' });
            }
        } finally {
            // 清理状态
            conversionSessionRef.current = null;
            setIsConverting(false);
            setProgress(0);
            // 删除临时输出文件,避免残留
            if (cachedOutputUriRef.current) {
                try {
                    const file = new File(cachedOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                cachedOutputUriRef.current = null;
            }
            if (lastOutputUriRef.current) {
                try {
                    const file = new File(lastOutputUriRef.current);
                    if (file.exists) {
                        file.delete();
                    }
                } catch (delErr) {
                    showNotification({ tip: '删除临时输出文件失败', type: 'error' });
                }
                lastOutputUriRef.current = null;
            }
        }
    };
    /**
     * 确定修改信息
     * @param info 
     * @returns Promise<void>
     */
    const handleConfirm = async (info?: UpdateSongInfoConfig | boolean) => {
        if (!convertVideoRef.current || typeof info === 'boolean') {
            return;
        }
        try {
            await saveConvertFile(info)
            setVisible(false);
        } catch (error) {
            showNotification({ tip: '更新文件信息失败,请检查文件权限', type: 'error' });
        }
    };
    /**
     * 取消修改 按默认名称保存
     * @returns Promise<void>
     */
    const handleCancelModify = async () => {
        await saveConvertFile();
    };
    return (
        <ThemedView style={[styles.container, { paddingTop: top, paddingBottom: bottom + 10 }]}>
            <TitleBar
                style={styles.top}
                leftComponent={
                    <Pressable
                        onPress={handleBack}
                        style={styles.topLeft}
                    >
                        <Ionicons name="chevron-back" size={22} color="#fff" />
                        <ThemedText style={styles.title}>视频转歌</ThemedText>
                    </Pressable>
                }
                rightComponent={
                    <ThemedText
                        style={styles.title}
                        onPress={handleConvertManage}
                    >
                        转换管理
                    </ThemedText>
                }
            />
            <VideoList
                onPress={handleConvertVideo}
            />
            <Modal
                visible={isConverting}
                transparent
                animationType="fade"
            >
                <View style={styles.modalContainer}>
                    < ProcessTip
                        progress={progress}
                        handleCancel={handleCancel}
                    />
                </View>
            </Modal>
            <UpdateOrDeleteModal
                visible={visible}
                needDeleteFile={false}
                setVisible={() => setVisible(false)}
                onConfirm={handleConfirm}
                onCancel={handleCancelModify}
                modalType='rename'
                audio={convertVideoRef.current as VideoAsset}
                fileName={outFileNameRef.current as string}
            />
        </ThemedView>
    );
};

const styles = StyleSheet.create({
    container: {
        flex: 1,
        alignItems: 'center',
    },
    top: {
        width: '100%',
    },
    title: {
        fontSize: 15,
        textAlign: 'center',
        fontWeight: 'bold',
    },
    topLeft: {
        flexDirection: 'row',
        alignItems: 'center',
    },
    modalContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
    },
});

export default FfmpegKitPage;

小结

开始咱们说流程很清晰,关键在于获取要转换的文件转换文件和保存文件以及后续的修改,具体的细节其实还是比较多 的,但是代码没有什么高深的特技,就像这篇文件一样,平铺直叙。如果说有困难应该在FFmpegKit依赖库的文档方面,因为这个依赖库好像已经没人维护了,有些API我是看的node_modules里的TS类型定义和AI辅助编写的,刚开始即便借助AI也是囫囵吞枣,出现大量转换失败的情况,然后就是调试过程中产生大量缓存文件没有清理,后来打印输出缓存也是挺有美感

123.png

文笔非常一般,但是也算干货满满,文笔不行图片来凑,而且今天我打包了APK安装到手机上,目前测很流畅,比开发构建版本的运行流畅,目前该应用只开发了android版本,ios没有设没有针对性处理调试,接下来再放一些其他模块图片,但是其他有技术难度的值得宣讲的不多,因为该应用没有配套后台,纯本地,就涉及到了数据存取问题,就是使用了Sqlite数据库,后面有时间考虑写一篇文章讲解项目中歌单存取处理,多个单切换设计,播放队列管理等,再放点图吧,上面细节不明白的有问题的欢迎留言探讨

微信图片_20260114172112_167_23.jpg

微信图片_20260114172113_168_23.jpg

微信图片_20260114172114_169_23.jpg

微信图片_20260114172115_170_23.jpg

微信图片_20260114172116_171_23.jpg

❌