/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Apng } from './Apng';
import { AnimationCallback } from '../types/ApngTypes';
import entry from 'libentry.so';
import image from '@ohos.multimedia.image';
import { fileIo as fs } from '@kit.CoreFileKit';
// Use OpenHarmony PixelMap type
type PixelMap = image.PixelMap;
/**
* Native wrapper object returned from C++ for a frame.
* pixelBuffer 为 BGRA_8888,供 image.createPixelMap 使用。
*/
interface NativePixelMapWrapper {
pixelBuffer: ArrayBuffer;
width: number;
height: number;
targetWidth: number;
targetHeight: number;
}
/**
* Size definition for PixelMap initialization.
*/
interface PixelMapSize {
width: number;
height: number;
}
/**
* Initialization options for image.createPixelMap.
*/
interface PixelMapInitOptions {
size: PixelMapSize;
}
/**
* Constants for loop count
*/
export const LOOP_FOREVER = 0;
export const LOOP_INTRINSIC = -1;
/**
* An animated drawable that plays the frames of an animated PNG.
*/
export class ApngDrawable {
/** 防止 setInterval 叠帧或切后台后单帧 Δt 过大导致时间轴暴走、提前结束 */
private static readonly MAX_FRAME_DELTA_MS = 100;
private apng: Apng;
private loopCount: number;
private isStarted: boolean = false;
private animationElapsedTimeMillis: number = 0;
private animationPrevDrawTimeMillis: number | null = null;
private frameStartTimes: number[] = [];
private callbacks: AnimationCallback[] = [];
private pixelMap: PixelMap | null = null;
private frameBuffer: ArrayBuffer;
private targetWidth: number | undefined;
private targetHeight: number | undefined;
// Constants
public readonly durationMillis: number;
public readonly frameCount: number;
public readonly frameDurations: number[];
public readonly frameByteCount: number;
public readonly allocationByteCount: number;
constructor(apng: Apng, width?: number, height?: number) {
this.apng = apng;
this.durationMillis = apng.duration;
this.frameCount = apng.frameCount;
this.frameDurations = apng.frameDurations;
this.frameByteCount = apng.width * apng.height * 4;
this.allocationByteCount = apng.allFrameByteCount + this.frameByteCount;
this.loopCount = apng.loopCount === 0 ? LOOP_FOREVER : apng.loopCount;
this.targetWidth = width;
this.targetHeight = height;
// Calculate frame start times
this.frameStartTimes = new Array(this.frameCount);
this.frameStartTimes[0] = 0;
for (let i = 1; i < this.frameCount; i++) {
this.frameStartTimes[i] = this.frameStartTimes[i - 1] + this.frameDurations[i - 1];
}
// Create frame buffer
this.frameBuffer = new ArrayBuffer(this.frameByteCount);
}
/**
* The number of times to loop this APNG image.
*/
public getLoopCount(): number {
return this.loopCount;
}
public setLoopCount(count: number): void {
if (count < LOOP_INTRINSIC) {
throw new Error(`loopCount must be >= ${LOOP_INTRINSIC}`);
}
this.loopCount = count === LOOP_INTRINSIC ? this.apng.loopCount : count;
}
/**
* Whether this drawable has already been destroyed or not.
*/
public get isRecycled(): boolean {
return this.apng.isRecycled;
}
/**
* The number indicating the current loop index.
*/
public get currentLoopIndex(): number {
if (this.durationMillis === 0) {
return 0;
}
const index = Math.floor(this.animationElapsedTimeMillis / this.durationMillis);
return Math.min(index, this.loopCount === LOOP_FOREVER ? index : this.loopCount - 1);
}
/**
* The corresponding frame index with the elapsed time of the animation.
*/
public get currentFrameIndex(): number {
let progressMillisInCurrentLoop =
this.durationMillis === 0 ? 0 : this.animationElapsedTimeMillis % this.durationMillis;
progressMillisInCurrentLoop += this.exceedsRepeatCountLimitation() ? this.durationMillis : 0;
return this.calculateCurrentFrameIndex(0, this.frameCount - 1, progressMillisInCurrentLoop);
}
/**
* Whether the animation is running.
*/
public isRunning(): boolean {
return this.isStarted;
}
/**
* Starts the animation.
* 参考 Android ApngDrawable.start():设置 isStarted 和 animationPrevDrawTimeMillis=null,
* 由外部驱动(Android 的 invalidateSelf,Harmony 的 setInterval)持续调用 getCurrentFrame 更新帧。
*/
public start(): void {
this.isStarted = true;
this.animationElapsedTimeMillis = 0; // 每次 start 从头开始,与用户预期一致
this.animationPrevDrawTimeMillis = null;
}
/**
* Stops the animation.
*/
public stop(): void {
this.isStarted = false;
}
/**
* Seeks frame to given position.
*/
public seekTo(positionMillis: number): void {
if (positionMillis < 0) {
throw new Error('positionMillis must be positive value');
}
this.animationPrevDrawTimeMillis = null;
this.animationElapsedTimeMillis = positionMillis;
}
/**
* Seeks frame to given frame index with loop index.
*/
public seekToFrame(loopIndex: number, frameIndex: number): void {
if (loopIndex < 0) {
throw new Error('loopIndex must be positive value');
}
if (frameIndex < 0) {
throw new Error('frameIndex must be positive value');
}
if (loopIndex >= this.loopCount && this.loopCount !== LOOP_FOREVER) {
throw new Error(`loopIndex must be less than loopCount. loopIndex = ${loopIndex}, loopCount = ${this.loopCount}`);
}
if (frameIndex >= this.frameCount) {
throw new Error(`frameIndex must be less than frameCount. frameIndex = ${frameIndex}, frameCount = ${this.frameCount}`);
}
this.seekTo(loopIndex * this.durationMillis + this.frameStartTimes[frameIndex]);
}
/**
* Registers animation callback.
*/
public registerAnimationCallback(callback: AnimationCallback): void {
this.callbacks.push(callback);
}
/**
* Unregisters animation callback.
*/
public unregisterAnimationCallback(callback: AnimationCallback): boolean {
const index = this.callbacks.indexOf(callback);
if (index >= 0) {
this.callbacks.splice(index, 1);
return true;
}
return false;
}
/**
* Clears all animation callbacks.
*/
public clearAnimationCallbacks(): void {
this.callbacks = [];
}
/**
* Creates a copy of this drawable.
*/
public copy(): ApngDrawable {
const copiedApng = this.apng.copy();
const drawable = new ApngDrawable(copiedApng);
drawable.setLoopCount(this.loopCount);
return drawable;
}
/**
* Releases resources managed by the native layer.
*/
public recycle(): void {
this.apng.recycle();
this.callbacks = [];
}
/**
* Gets the current frame as PixelMap.
*/
public async getCurrentFrame(): Promise<PixelMap | null> {
if (this.isRecycled) {
return null;
}
// Update animation time if started
if (this.isStarted) {
this.progressAnimationElapsedTime();
}
const frameIndex = this.currentFrameIndex;
if (!this.apng.drawWithIndex(frameIndex, this.frameBuffer)) {
return null;
}
// Create PixelMap from RGBA buffer using C++ NAPI + OpenHarmony image API
try {
// Determine target size (support scaling)
const targetWidth = this.targetWidth ?? this.apng.width;
const targetHeight = this.targetHeight ?? this.apng.height;
// Call C++ NAPI function to create a wrapper object that contains RGBA data and size info
const wrapper = entry.apng_drawable.createPixelMapFromBuffer(
this.frameBuffer,
this.apng.width,
this.apng.height,
targetWidth,
targetHeight
) as NativePixelMapWrapper | null;
if (!wrapper) {
console.error('Failed to create PixelMap wrapper from native layer');
return null;
}
// Use OpenHarmony image.createPixelMap:官方要求 colors 为 BGRA_8888;native createPixelMapFromBuffer 已从 RGBA 转换。
// wrapper.width/height 为缩放后的尺寸(C++ 层已处理缩放)
const size: PixelMapSize = {
width: wrapper.width, // 已经是 targetWidth(缩放后)
height: wrapper.height // 已经是 targetHeight(缩放后)
};
const initOptions: PixelMapInitOptions = {
size: size
};
const pixelMap = await image.createPixelMap(wrapper.pixelBuffer, initOptions);
return pixelMap as PixelMap;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error('Failed to create PixelMap:', errorMsg);
return null;
}
}
/**
* Exports the current frame as a Bitmap (PixelMap).
* This is equivalent to getCurrentFrame() - returns the current frame as PixelMap.
* @returns Promise resolving to PixelMap of the current frame, or null if failed
*/
public async exportAsBitmap(): Promise<PixelMap | null> {
return this.getCurrentFrame();
}
/**
* Saves the current frame as a PNG file.
* @param filePath The path to save the PNG file
* @returns Promise resolving to true if successful, false otherwise
*/
public async saveAsPng(filePath: string): Promise<boolean> {
const pixelMap = await this.getCurrentFrame();
if (!pixelMap) {
console.error('saveAsPng: Failed to get current frame');
return false;
}
try {
const packer = image.createImagePacker();
const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
const file = fs.openSync(filePath, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE);
await packer.packToFile(pixelMap, file.fd, packOpts);
fs.closeSync(file.fd);
packer.release();
return true;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error('saveAsPng: Failed to save image:', errorMsg);
return false;
}
}
private progressAnimationElapsedTime(): void {
const lastFrame = this.currentFrameIndex;
const currentTimeMillis = Date.now();
const animationPrevDrawTimeMillisSnapshot = this.animationPrevDrawTimeMillis;
let deltaMs = 0;
if (animationPrevDrawTimeMillisSnapshot !== null) {
const raw = currentTimeMillis - animationPrevDrawTimeMillisSnapshot;
deltaMs = Math.min(Math.max(raw, 0), ApngDrawable.MAX_FRAME_DELTA_MS);
}
this.animationElapsedTimeMillis = animationPrevDrawTimeMillisSnapshot === null
? this.animationElapsedTimeMillis
: this.animationElapsedTimeMillis + deltaMs;
this.animationPrevDrawTimeMillis = currentTimeMillis;
const frameChanged = this.currentFrameIndex !== lastFrame;
if (this.isStarted) {
if (
this.isFirstFrame() &&
this.isFirstLoop() &&
animationPrevDrawTimeMillisSnapshot === null
) {
// Animation start
this.callbacks.forEach(cb => cb.onAnimationStart?.());
} else if (
this.isLastFrame() &&
this.hasNextLoop() &&
frameChanged
) {
// Animation repeat
const nextLoopIndex = this.currentLoopIndex + 1;
this.callbacks.forEach(cb => cb.onAnimationRepeat?.(nextLoopIndex));
}
}
if (this.exceedsRepeatCountLimitation()) {
this.isStarted = false;
this.callbacks.forEach(cb => cb.onAnimationEnd?.());
}
}
private isFirstFrame(): boolean {
return this.currentFrameIndex === 0;
}
private isLastFrame(): boolean {
return this.currentFrameIndex === this.frameCount - 1;
}
private isFirstLoop(): boolean {
return Math.floor(this.animationElapsedTimeMillis / this.durationMillis) === 0;
}
private hasNextLoop(): boolean {
if (this.loopCount === LOOP_FOREVER) {
return true;
}
return Math.floor(this.animationElapsedTimeMillis / this.durationMillis) < this.loopCount - 1;
}
private exceedsRepeatCountLimitation(): boolean {
if (this.loopCount === LOOP_FOREVER) {
return false;
}
return Math.floor(this.animationElapsedTimeMillis / this.durationMillis) > this.loopCount - 1;
}
private calculateCurrentFrameIndex(
lowerBoundIndex: number,
upperBoundIndex: number,
progressMillisInCurrentLoop: number
): number {
const middleIndex = Math.floor((lowerBoundIndex + upperBoundIndex) / 2);
if (
this.frameStartTimes.length > middleIndex + 1 &&
progressMillisInCurrentLoop >= this.frameStartTimes[middleIndex + 1]
) {
return this.calculateCurrentFrameIndex(
middleIndex + 1,
upperBoundIndex,
progressMillisInCurrentLoop
);
}
if (
lowerBoundIndex !== upperBoundIndex &&
progressMillisInCurrentLoop < this.frameStartTimes[middleIndex]
) {
return this.calculateCurrentFrameIndex(
lowerBoundIndex,
middleIndex,
progressMillisInCurrentLoop
);
}
return middleIndex;
}
/**
* Static method to decode APNG from ArrayBuffer.
*/
public static decode(buffer: ArrayBuffer, width?: number, height?: number): ApngDrawable {
if ((width === undefined) !== (height === undefined)) {
throw new Error('Can not specify only one side of size');
}
if (width !== undefined && width <= 0) {
throw new Error(`Can not specify 0 or negative as width value. width = ${width}`);
}
if (height !== undefined && height <= 0) {
throw new Error(`Can not specify 0 or negative as height value. height = ${height}`);
}
const apng = Apng.decode(buffer);
return new ApngDrawable(apng, width, height);
}
/**
* Static method to check if buffer is APNG.
*/
public static isApng(buffer: ArrayBuffer): boolean {
return Apng.isApng(buffer);
}
}