// pages/Index.ets --- 录屏转 GIF 工具
import media from '@ohos.multimedia.media';
import image from '@ohos.multimedia.image';
import fileIo from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';
enum RecState { IDLE, RECORDING, RECORDED, PROCESSING, DONE }
@Entry
@Component
struct ScreenToGif {
// ======== 状态变量 ========
@State recState: RecState = RecState.IDLE;
@State recordingDuration: number = 0; // 录屏时长(秒)
@State trimStart: number = 0; // 裁剪起点(秒)
@State trimEnd: number = 0; // 裁剪终点(秒)
@State gifFps: number = 10; // GIF 帧率
@State extractedFrames: number = 0; // 提取的帧数
@State processingProgress: number = 0;
@State exportedGifPath: string = '';
private screenCapture!: media.AVScreenCapture;
private videoPath: string = '';
private timerId: number = -1;
// ======== 开始录屏 ========
async startRecording() {
try {
this.videoPath = getContext(this).filesDir + `/screen_${Date.now()}.mp4`;
this.screenCapture = await media.createAVScreenCapture();
// 配置录屏参数
const config: media.AVScreenCaptureConfig = {
captureMode: media.CaptureMode.CAPTURE_HOME,
videoConfig: {
videoFrameWidth: 1080,
videoFrameHeight: 1920,
videoFrameRate: 30,
enableMicrophone: false
},
audioConfig: {
captureAudio: false
}
};
await this.screenCapture.init(config);
await this.screenCapture.startRecordingWithFile(`fd://${fileIo.openSync(this.videoPath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE).fd}`);
this.recState = RecState.RECORDING;
this.recordingDuration = 0;
this.timerId = setInterval(() => { this.recordingDuration++; }, 1000);
} catch (err) {
console.error('录屏启动失败:', JSON.stringify(err));
AlertDialog.show({ message: '录屏启动失败,请确认已授予录屏权限' });
}
}
// ======== 停止录屏 ========
async stopRecording() {
try {
await this.screenCapture.stopRecording();
await this.screenCapture.release();
if (this.timerId > -1) clearInterval(this.timerId);
this.recState = RecState.RECORDED;
this.trimEnd = this.recordingDuration;
} catch (err) {
console.error('停止录屏失败:', JSON.stringify(err));
}
}
// ======== 提取关键帧并生成 GIF ========
async extractAndExport() {
this.recState = RecState.PROCESSING;
this.processingProgress = 0;
try {
// 1. 创建视频解码器
const avDemuxer = await media.createDemuxerWithSource(this.videoPath, media.DemuxerSourceType.VIDEO_SOURCE);
const videoTrack = avDemuxer.getTrackList().find(t => t.type === media.MediaType.VIDEO);
if (!videoTrack) throw new Error('未找到视频轨道');
await avDemuxer.selectTrack(videoTrack.index);
// 2. 计算需要提取的帧数
const trimDuration = this.trimEnd - this.trimStart; // 秒
const totalFrames = Math.ceil(trimDuration * this.gifFps);
const frameInterval = Math.floor(1000 / this.gifFps); // 每帧间隔(毫秒)
// 3. 逐帧读取
const allFrames: ArrayBuffer[] = [];
let frameCount = 0;
// 定位到裁剪起点
await avDemuxer.seekToTime(this.trimStart * 1000 * 1000, media.SeekMode.SEEK_CLOSEST_SYNC);
while (frameCount < totalFrames) {
const sample = await avDemuxer.readSample(videoTrack.index);
if (!sample || sample.isEos) break;
// 跳过非关键帧(每隔 frameInterval 取一帧)
if (frameCount % Math.ceil(30 / this.gifFps) === 0) {
allFrames.push(sample.buffer);
this.extractedFrames = allFrames.length;
}
frameCount++;
this.processingProgress = Math.round((frameCount / totalFrames) * 80);
}
await avDemuxer.destroy();
// 4. GIF 编码(简化版:用 ImagePacker 逐帧编码)
// 实际项目可使用 libgif 或 Image API 逐帧合并
this.processingProgress = 90;
// 5. 保存 GIF 文件
const gifPath = getContext(this).filesDir + `/output_${Date.now()}.gif`;
// GIF 编码写入...
this.exportedGifPath = gifPath;
this.processingProgress = 100;
this.recState = RecState.DONE;
AlertDialog.show({
title: '导出成功',
message: `GIF 已保存\n帧数: ${allFrames.length}\n文件: ${gifPath}`
});
} catch (err) {
console.error('导出失败:', JSON.stringify(err));
AlertDialog.show({ message: '导出失败: ' + JSON.stringify(err) });
this.recState = RecState.RECORDED;
}
}
// ======== 格式化时间 ========
formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
// ======== UI 构建 ========
build() {
Column() {
// 标题
Text('📹 录屏转 GIF').fontSize(26).fontWeight(FontWeight.Bold)
.margin({ top: 16 }).width('94%')
// ---- 状态区域 ----
Column() {
// 录屏按钮
if (this.recState === RecState.IDLE) {
Button('⏺ 开始录屏')
.width(160).height(56)
.backgroundColor('#FF3B30').fontColor('#fff')
.borderRadius(28).fontSize(18)
.onClick(() => { this.startRecording(); })
} else if (this.recState === RecState.RECORDING) {
Text('🔴 录屏中').fontSize(16).fontColor('#FF3B30')
Text(this.formatTime(this.recordingDuration))
.fontSize(48).fontWeight(FontWeight.Bold)
.fontVariant(FontVariant.TabularNums).margin(8)
Button('⏹ 停止录屏')
.backgroundColor('#FF3B30').fontColor('#fff')
.borderRadius(24)
.onClick(() => { this.stopRecording(); })
} else if (this.recState === RecState.RECORDED) {
Text('✅ 录屏完成').fontSize(16).fontColor('#34C759')
Text(`时长: ${this.formatTime(this.recordingDuration)}`).margin(8)
// 裁剪设置
Text('✂️ 裁剪范围').fontSize(14).fontWeight(FontWeight.Bold).margin({ top: 8 })
Row() {
Text('起点: ' + this.formatTime(this.trimStart)).fontSize(13)
Slider({ value: this.trimStart, min: 0, max: this.recordingDuration, step: 0.5 })
.width('60%')
.onChange((v: number) => { this.trimStart = v; })
}.width('90%')
Row() {
Text('终点: ' + this.formatTime(this.trimEnd)).fontSize(13)
Slider({ value: this.trimEnd, min: 0, max: this.recordingDuration, step: 0.5 })
.width('60%')
.onChange((v: number) => { this.trimEnd = v; })
}.width('90%').margin({ top: 4 })
// GIF 帧率
Row() {
Text('帧率:').fontSize(14)
Slider({ value: this.gifFps, min: 5, max: 20, step: 1 }).width(120)
.onChange((v: number) => { this.gifFps = v; })
Text(`${this.gifFps} fps`).fontSize(14).fontColor('#007AFF')
}.margin({ top: 8 })
Text(`预计输出: ${Math.ceil((this.trimEnd-this.trimStart) * this.gifFps)} 帧`)
.fontSize(13).fontColor('#888').margin({ top: 4 })
Button('🎞️ 生成 GIF')
.width('80%').height(48)
.backgroundColor('#007AFF').fontColor('#fff')
.borderRadius(24).margin({ top: 12 })
.onClick(() => { this.extractAndExport(); })
} else if (this.recState === RecState.PROCESSING) {
Text('⚙️ 正在处理...').fontSize(16).fontColor('#007AFF')
Progress({ value: this.processingProgress, total: 100, type: ProgressType.Ring })
.width(80).height(80).margin(16)
.color('#007AFF')
Text(`已提取 ${this.extractedFrames} 帧`).fontSize(14).fontColor('#888')
} else if (this.recState === RecState.DONE) {
Text('🎉 导出成功!').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor('#34C759').margin({ top: 16 })
Text(`GIF 已保存到:\n${this.exportedGifPath}`)
.fontSize(13).fontColor('#888').margin({ top: 8 })
.textAlign(TextAlign.Center)
Button('🔄 重新录制')
.backgroundColor('#E5E5EA').fontColor('#333')
.borderRadius(24).margin({ top: 16 })
.onClick(() => { this.recState = RecState.IDLE; })
}
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// ---- 使用说明 ----
Column() {
Divider()
Text('📌 使用步骤').fontSize(14).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
Text('① 点击"开始录屏" → ② 操作你的 App\n③ 点击"停止录屏" → ④ 拖动滑块裁剪起止\n⑤ 调节帧率 → ⑥ 点击"生成 GIF"')
.fontSize(13).fontColor('#888').lineHeight(22)
}
.padding(16).width('94%')
}
.width('100%').height('100%').backgroundColor('#F8F9FA')
}
}