1 /++
2 	Support for [https://wiki.mozilla.org/APNG_Specification|animated png] files.
3 
4 	$(WARNING Please note this interface is not exactly stable and may break with minimum notice.)
5 
6 	History:
7 		Originally written March 2019 with read support.
8 
9 		Render support added December 28, 2020.
10 
11 		Write support added February 27, 2021.
12 +/
13 module arsd.apng;
14 
15 /// Demo creating one from scratch
16 unittest {
17 	import arsd.apng;
18 
19 	void main() {
20 		auto apng = new ApngAnimation(50, 50);
21 
22 		auto frame = apng.addFrame(25, 25);
23 		frame.data[] = 255;
24 
25 		frame = apng.addFrame(25, 25);
26 		frame.data[] = 255;
27 		frame.frameControlChunk.delay_num = 10;
28 
29 		frame = apng.addFrame(25, 25);
30 		frame.data[] = 255;
31 		frame.frameControlChunk.x_offset = 25;
32 		frame.frameControlChunk.delay_num = 10;
33 
34 		frame = apng.addFrame(25, 25);
35 		frame.data[] = 255;
36 		frame.frameControlChunk.y_offset = 25;
37 		frame.frameControlChunk.delay_num = 10;
38 
39 		frame = apng.addFrame(25, 25);
40 		frame.data[] = 255;
41 		frame.frameControlChunk.x_offset = 25;
42 		frame.frameControlChunk.y_offset = 25;
43 		frame.frameControlChunk.delay_num = 10;
44 
45 
46 		writeApngToFile(apng, "/home/me/test.apng");
47 	}
48 
49 	version(Demo) main(); // exclude from docs
50 }
51 
52 /// Demo reading and rendering
53 unittest {
54 	import arsd.simpledisplay;
55 	import arsd.game;
56 	import arsd.apng;
57 
58 	void main(string[] args) {
59 		import std.file;
60 		auto a = readApng(cast(ubyte[]) std.file.read(args[1]));
61 
62 		auto window = create2dWindow("Animated PNG viewer", a.header.width, a.header.height);
63 
64 		auto render = a.renderer();
65 		OpenGlTexture[] frames;
66 		int[] waits;
67 		foreach(frame; a.frames) {
68 			waits ~= render.nextFrame();
69 			// this would be the raw data for the frame
70 			//frames ~= new OpenGlTexture(frame.frameData.getAsTrueColorImage);
71 			// or the current rendered ersion
72 			frames ~= new OpenGlTexture(render.buffer);
73 		}
74 
75 		int pos;
76 		int currentWait;
77 
78 		void update() {
79 			currentWait += waits[pos];
80 			pos++;
81 			if(pos == frames.length)
82 				pos = 0;
83 		}
84 
85 		window.redrawOpenGlScene = () {
86 			glClear(GL_COLOR_BUFFER_BIT);
87 			frames[pos].draw(0, 0);
88 		};
89 
90 		auto tick = 50;
91 		window.eventLoop(tick, delegate() {
92 			currentWait -= tick;
93 			auto updateNeeded = currentWait <= 0;
94 			while(currentWait <= 0)
95 				update();
96 			if(updateNeeded)
97 				window.redrawOpenGlSceneNow();
98 		//},
99 		//(KeyEvent ev) {
100 		//if(ev.pressed)
101 		});
102 
103 		// writeApngToFile(a, "/home/me/test.apng");
104 	}
105 
106 	version(Demo) main(["", "/home/me/test.apng"]); // exclude from docs
107 	//version(Demo) main(["", "/home/me/small-clouds.png"]); // exclude from docs
108 }
109 
110 import arsd.png;
111 
112 // must be in the file before the IDAT
113 /// acTL chunk direct representation
114 struct AnimationControlChunk {
115 	uint num_frames;
116 	uint num_plays;
117 
118 	/// Adds it to a chunk payload buffer, returning the slice of `buffer` actually used
119 	/// Used internally by the [writeApngToFile] family of functions.
120 	ubyte[] toChunkPayload(ubyte[] buffer)
121 		in { assert(buffer.length >= 8); }
122 	do {
123 		int offset = 0;
124 		buffer[offset++] = (num_frames >> 24) & 0xff;
125 		buffer[offset++] = (num_frames >> 16) & 0xff;
126 		buffer[offset++] = (num_frames >>  8) & 0xff;
127 		buffer[offset++] = (num_frames >>  0) & 0xff;
128 
129 		buffer[offset++] = (num_plays >> 24) & 0xff;
130 		buffer[offset++] = (num_plays >> 16) & 0xff;
131 		buffer[offset++] = (num_plays >>  8) & 0xff;
132 		buffer[offset++] = (num_plays >>  0) & 0xff;
133 
134 		return buffer[0 .. offset];
135 	}
136 }
137 
138 /// fcTL chunk direct representation
139 struct FrameControlChunk {
140 	align(1):
141 	// this should go up each time, for frame control AND for frame data, each increases.
142 	uint sequence_number;
143 	uint width;
144 	uint height;
145 	uint x_offset;
146 	uint y_offset;
147 	ushort delay_num;
148 	ushort delay_den;
149 	APNG_DISPOSE_OP dispose_op;
150 	APNG_BLEND_OP blend_op;
151 
152 	static assert(dispose_op.offsetof == 24);
153 	static assert(blend_op.offsetof == 25);
154 
155 	ubyte[] toChunkPayload(int sequenceNumber, ubyte[] buffer)
156 		in { assert(buffer.length >= typeof(this).sizeof); }
157 	do {
158 		int offset = 0;
159 
160 		sequence_number = sequenceNumber;
161 
162 		buffer[offset++] = (sequence_number >> 24) & 0xff;
163 		buffer[offset++] = (sequence_number >> 16) & 0xff;
164 		buffer[offset++] = (sequence_number >>  8) & 0xff;
165 		buffer[offset++] = (sequence_number >>  0) & 0xff;
166 
167 		buffer[offset++] = (width >> 24) & 0xff;
168 		buffer[offset++] = (width >> 16) & 0xff;
169 		buffer[offset++] = (width >>  8) & 0xff;
170 		buffer[offset++] = (width >>  0) & 0xff;
171 
172 		buffer[offset++] = (height >> 24) & 0xff;
173 		buffer[offset++] = (height >> 16) & 0xff;
174 		buffer[offset++] = (height >>  8) & 0xff;
175 		buffer[offset++] = (height >>  0) & 0xff;
176 
177 		buffer[offset++] = (x_offset >> 24) & 0xff;
178 		buffer[offset++] = (x_offset >> 16) & 0xff;
179 		buffer[offset++] = (x_offset >>  8) & 0xff;
180 		buffer[offset++] = (x_offset >>  0) & 0xff;
181 
182 		buffer[offset++] = (y_offset >> 24) & 0xff;
183 		buffer[offset++] = (y_offset >> 16) & 0xff;
184 		buffer[offset++] = (y_offset >>  8) & 0xff;
185 		buffer[offset++] = (y_offset >>  0) & 0xff;
186 
187 		buffer[offset++] = (delay_num >>  8) & 0xff;
188 		buffer[offset++] = (delay_num >>  0) & 0xff;
189 
190 		buffer[offset++] = (delay_den >>  8) & 0xff;
191 		buffer[offset++] = (delay_den >>  0) & 0xff;
192 
193 		buffer[offset++] = cast(ubyte) dispose_op;
194 		buffer[offset++] = cast(ubyte) blend_op;
195 
196 		return buffer[0 .. offset];
197 	}
198 }
199 
200 /++
201 	Represents a single frame from the file, directly corresponding to the fcTL and fdAT data from the file.
202 +/
203 class ApngFrame {
204 
205 	ApngAnimation parent;
206 
207 	this(ApngAnimation parent) {
208 		this.parent = parent;
209 	}
210 
211 	this(ApngAnimation parent, int width, int height) {
212 		this.parent = parent;
213 		frameControlChunk.width = width;
214 		frameControlChunk.height = height;
215 
216 		if(parent.header.type == 3) { // FIXME: other types?!
217 			auto ii = new IndexedImage(width, height);
218 			ii.palette = parent.palette;
219 			frameData = ii;
220 			data = ii.data;
221 		} else {
222 			auto tci = new TrueColorImage(width, height);
223 			frameData = tci;
224 			data = tci.imageData.bytes;
225 		}
226 	}
227 
228 	void resyncData() {
229 		if(frameData is null)
230 			populateData();
231 
232 		assert(frameData !is null);
233 		assert(frameData.width == frameControlChunk.width);
234 		assert(frameData.height == frameControlChunk.height);
235 
236 		if(auto tci = cast(TrueColorImage) frameData) {
237 			data = tci.imageData.bytes;
238 			assert(parent.header.type == 6);
239 		} else if(auto ii = cast(IndexedImage) frameData) {
240 			data = ii.data;
241 			assert(parent.header.type == 3);
242 			assert(ii.palette == parent.palette);
243 		}
244 	}
245 
246 	/++
247 		You're allowed to edit these values but remember it is your responsibility to keep
248 		it consistent with the rest of the file (at least for now, I might change this in the future).
249 	+/
250 	FrameControlChunk frameControlChunk;
251 
252 	private ubyte[] compressedDatastream; /// Raw datastream from the file.
253 
254 	/++
255 		A reference to frameData's bytes. May be 8 bit if indexed or 32 bit rgba if not.
256 
257 		Do not replace this reference but you may edit the content.
258 	+/
259 	ubyte[] data;
260 
261 	/++
262 		Processed frame data as an image. only set after you call populateData.
263 
264 		You are allowed to edit the bytes on this but don't change the width/height or palette. Also don't replace the object.
265 
266 		This also means `getAsTrueColorImage` is not that useful, instead cast to [IndexedImage] or [TrueColorImage] depending
267 		on your type.
268 	+/
269 	MemoryImage frameData;
270 	/++
271 		Loads the raw [compressedDatastream] into raw uncompressed [data] and processed [frameData]
272 	+/
273 	void populateData() {
274 		if(data !is null)
275 			return;
276 
277 		import std.zlib;
278 
279 		auto raw = cast(ubyte[]) uncompress(compressedDatastream);
280 		auto bpp = bytesPerPixel(parent.header);
281 
282 		auto width = frameControlChunk.width;
283 		auto height = frameControlChunk.height;
284 
285 		auto bytesPerLine = bytesPerLineOfPng(parent.header.depth, parent.header.type, width);
286 		bytesPerLine--; // removing filter byte from this calculation since we handle separately
287 
288 		size_t idataIdx;
289 		ubyte[] idata;
290 
291 		MemoryImage img;
292 		if(parent.header.type == 3) {
293 			auto i = new IndexedImage(width, height);
294 			img = i;
295 			i.palette = parent.palette;
296 			idata = i.data;
297 		} else { // FIXME: other types?!
298 			auto i = new TrueColorImage(width, height);
299 			img = i;
300 			idata = i.imageData.bytes;
301 		}
302 
303 		immutable(ubyte)[] previousLine;
304 		foreach(y; 0 .. height) {
305 			auto filter = raw[0];
306 			raw = raw[1 .. $];
307 			auto line = raw[0 .. bytesPerLine];
308 			raw = raw[bytesPerLine .. $];
309 
310 			auto unfiltered = unfilter(filter, line, previousLine, bpp);
311 			previousLine = unfiltered;
312 
313 			convertPngData(parent.header.type, parent.header.depth, unfiltered, width, idata, idataIdx);
314 		}
315 
316 		this.data = idata;
317 		this.frameData = img;
318 	}
319 }
320 
321 /++
322 
323 +/
324 struct ApngRenderBuffer {
325 	/// Load this yourself
326 	ApngAnimation animation;
327 
328 	/// Then these are populated when you call [nextFrame]
329 	public TrueColorImage buffer;
330 	/// ditto
331 	public int frameNumber;
332 
333 	private FrameControlChunk prevFcc;
334 	private TrueColorImage[] convertedFrames;
335 	private TrueColorImage previousFrame;
336 
337 	/++
338 		Returns number of millisecond to wait until the next frame and populates [buffer] and [frameNumber].
339 	+/
340 	int nextFrame() {
341 		if(frameNumber == animation.frames.length) {
342 			frameNumber = 0;
343 			prevFcc = FrameControlChunk.init;
344 		}
345 
346 		auto frame = animation.frames[frameNumber];
347 		auto fcc = frame.frameControlChunk;
348 		if(convertedFrames is null) {
349 			convertedFrames = new TrueColorImage[](animation.frames.length);
350 		}
351 		if(convertedFrames[frameNumber] is null) {
352 			frame.populateData();
353 			convertedFrames[frameNumber] = frame.frameData.getAsTrueColorImage();
354 		}
355 
356 		final switch(prevFcc.dispose_op) {
357 			case APNG_DISPOSE_OP.NONE:
358 				break;
359 			case APNG_DISPOSE_OP.BACKGROUND:
360 				// clear area to 0
361 				foreach(y; prevFcc.y_offset .. prevFcc.y_offset + prevFcc.height)
362 					buffer.imageData.bytes[
363 						4 * (prevFcc.x_offset + y * buffer.width)
364 						..
365 						4 * (prevFcc.x_offset + prevFcc.width + y * buffer.width)
366 					] = 0;
367 				break;
368 			case APNG_DISPOSE_OP.PREVIOUS:
369 				// put the buffer back in
370 
371 				// this could prolly be more efficient, it only really cares about the prevFcc bounding box
372 				buffer.imageData.bytes[] = previousFrame.imageData.bytes[];
373 				break;
374 		}
375 
376 		prevFcc = fcc;
377 		// should copy the buffer at this point for a PREVIOUS case happening
378 		if(fcc.dispose_op == APNG_DISPOSE_OP.PREVIOUS) {
379 			// this could prolly be more efficient, it only really cares about the prevFcc bounding box
380 			if(previousFrame is null){
381 				previousFrame = buffer.clone();
382 			} else {
383 				previousFrame.imageData.bytes[] = buffer.imageData.bytes[];
384 			}
385 		}
386 
387 		size_t foff;
388 		foreach(y; fcc.y_offset .. fcc.y_offset + fcc.height) {
389 			final switch(fcc.blend_op) {
390 				case APNG_BLEND_OP.SOURCE:
391 					buffer.imageData.bytes[
392 						4 * (fcc.x_offset + y * buffer.width)
393 						..
394 						4 * (fcc.x_offset + y * buffer.width + fcc.width)
395 					] = convertedFrames[frameNumber].imageData.bytes[foff .. foff + fcc.width * 4];
396 					foff += fcc.width * 4;
397 				break;
398 				case APNG_BLEND_OP.OVER:
399 					foreach(x; fcc.x_offset .. fcc.x_offset + fcc.width) {
400 						buffer.imageData.colors[y * buffer.width + x] =
401 							alphaBlend(
402 								convertedFrames[frameNumber].imageData.colors[foff],
403 								buffer.imageData.colors[y * buffer.width + x]
404 							);
405 						foff++;
406 					}
407 				break;
408 			}
409 		}
410 
411 		frameNumber++;
412 
413 		if(fcc.delay_den == 0)
414 			return fcc.delay_num * 1000 / 100;
415 		else
416 			return fcc.delay_num * 1000 / fcc.delay_den;
417 	}
418 }
419 
420 /++
421 	Class that represents an apng file.
422 +/
423 class ApngAnimation {
424 	PngHeader header;
425 	AnimationControlChunk acc;
426 	Color[] palette;
427 	ApngFrame[] frames;
428 	// default image? tho i can just load it as a png for that too.
429 
430 	/++
431 		This is an uninitialized thing, you're responsible for filling in all data yourself. You probably don't want to
432 		use this except for use in the `factory` function you pass to [readApng].
433 	+/
434 	this() {
435 
436 	}
437 
438 	/++
439 		If palette is null, it is a true color image. If it has data, it is indexed.
440 	+/
441 	this(int width, int height, Color[] palette = null) {
442 		header.type = (palette !is null) ? 3 : 6;
443 		header.width = width;
444 		header.height = height;
445 
446 		this.palette = palette;
447 	}
448 
449 	/++
450 		Adds a frame with the given size and returns the object. You can change other values in the frameControlChunk on it
451 		and get the data bytes out of there.
452 	+/
453 	ApngFrame addFrame(int width, int height) {
454 		assert(width <= header.width);
455 		assert(height <= header.height);
456 		auto f = new ApngFrame(this, width, height);
457 		frames ~= f;
458 		acc.num_frames++;
459 		return f;
460 	}
461 
462 	// call before writing or trying to render again
463 	void resyncData() {
464 		acc.num_frames = cast(int) frames.length;
465 		foreach(frame; frames)
466 			frame.resyncData();
467 	}
468 
469 	///
470 	ApngRenderBuffer renderer() {
471 		return ApngRenderBuffer(this, new TrueColorImage(header.width, header.height), 0);
472 	}
473 
474 	/++
475 		Hook for subclasses to handle custom chunks in the png file as it is loaded by [readApng].
476 
477 		Examples:
478 			---
479 			override void handleOtherChunkWhenLoading(Chunk chunk) {
480 				if(chunk.stype == "mine") {
481 					ubyte[] data = chunk.payload;
482 					// process it
483 				}
484 			}
485 			---
486 
487 		History:
488 			Added December 26, 2021 (dub v10.5)
489 	+/
490 	protected void handleOtherChunkWhenLoading(Chunk chunk) {
491 		// intentionally blank to ignore it since the main function does the whole base functionality
492 	}
493 
494 	/++
495 		Hook for subclasses to add custom chunks to the png file as it is written by [writeApngToData] and [writeApngToFile].
496 
497 		Standards:
498 			See the png spec for guidelines on how to create non-essential, private chunks in a file:
499 
500 			http://www.libpng.org/pub/png/spec/1.2/PNG-Encoders.html#E.Use-of-private-chunks
501 
502 		Examples:
503 			---
504 			override createOtherChunksWhenSaving(scope void delegate(Chunk c) sink) {
505 				sink(*Chunk.create("mine", [payload, bytes, here]));
506 			}
507 			---
508 
509 		History:
510 			Added December 26, 2021 (dub v10.5)
511 	+/
512 	protected void createOtherChunksWhenSaving(scope void delegate(Chunk c) sink) {
513 		// no other chunks by default
514 
515 		// I can now do the repeat frame thing for start / cycle / end bits of the animation in the game!
516 	}
517 }
518 
519 ///
520 enum APNG_DISPOSE_OP : byte {
521 	NONE = 0, ///
522 	BACKGROUND = 1, ///
523 	PREVIOUS = 2 ///
524 }
525 
526 ///
527 enum APNG_BLEND_OP : byte {
528 	SOURCE = 0, ///
529 	OVER = 1 ///
530 }
531 
532 /++
533 	Loads an apng file.
534 
535 	Params:
536 		data = the raw data bytes of the file
537 		strictApng = if true, it will strictly interpret
538 		the file as apng and ignore the default image. If there
539 		are no animation chunks, it will return an empty ApngAnimation
540 		object.
541 
542 		If false, it will use the default image as the first
543 		(and only) frame of animation if there are no apng chunks.
544 
545 		factory = factory function for constructing the [ApngAnimation]
546 		object the function returns. You can use this to override the
547 		allocation pattern or to return a subclass instead, which can handle
548 		custom chunks and other things.
549 
550 	History:
551 		Parameter `strictApng` added February 27, 2021
552 		Parameter `factory` added December 26, 2021
553 +/
554 ApngAnimation readApng(in ubyte[] data, bool strictApng = false, scope ApngAnimation delegate() factory = null) {
555 	auto png = readPng(data);
556 	auto header = PngHeader.fromChunk(png.chunks[0]);
557 
558 	ApngAnimation obj;
559 	if(factory)
560 		obj = factory();
561 	else
562 		obj = new ApngAnimation();
563 
564 	obj.header = header;
565 
566 	if(header.type == 3) {
567 		obj.palette = fetchPalette(png);
568 	}
569 
570 	bool seenIdat = false;
571 	bool seenFctl = false;
572 
573 	int frameNumber;
574 	int expectedSequenceNumber = 0;
575 
576 	bool seenacTL = false;
577 
578 	foreach(chunk; png.chunks) {
579 		switch(chunk.stype) {
580 			case "IDAT":
581 
582 				if(!seenacTL && !strictApng) {
583 					// acTL chunks must appear before IDAT per spec,
584 					// so if there isn't one by now, it isn't an apng file.
585 					// but unless we care about strictApng, we can salvage
586 					// by making some dummy data.
587 
588 					{
589 						AnimationControlChunk c;
590 						c.num_frames = 1;
591 						c.num_plays = 1;
592 
593 						obj.acc = c;
594 						obj.frames = new ApngFrame[](c.num_frames);
595 
596 						seenacTL = true;
597 					}
598 
599 					{
600 						FrameControlChunk c;
601 						c.sequence_number = 1;
602 						c.width = header.width;
603 						c.height = header.height;
604 						c.x_offset = 0;
605 						c.y_offset = 0;
606 						c.delay_num = short.max;
607 						c.delay_den = 1;
608 						c.dispose_op = APNG_DISPOSE_OP.NONE;
609 						c.blend_op = APNG_BLEND_OP.SOURCE;
610 
611 						seenFctl = true;
612 
613 						// not increasing expectedSequenceNumber since if something is present, this is malformed!
614 
615 						if(obj.frames[frameNumber] is null)
616 							obj.frames[frameNumber] = new ApngFrame(obj);
617 						obj.frames[frameNumber].frameControlChunk = c;
618 
619 						frameNumber++;
620 					}
621 				}
622 
623 
624 				seenIdat = true;
625 				// all I care about here are animation frames,
626 				// so if this isn't after a control chunk, I'm
627 				// just going to ignore it. Read the file with
628 				// readPng if you want that.
629 				if(!seenFctl)
630 					continue;
631 
632 				assert(frameNumber == 1); // we work on frame 0 but fcTL advances it
633 				assert(obj.frames[0]);
634 
635 				obj.frames[0].compressedDatastream ~= chunk.payload;
636 			break;
637 			case "acTL":
638 				AnimationControlChunk c;
639 				int offset = 0;
640 				c.num_frames |= chunk.payload[offset++] << 24;
641 				c.num_frames |= chunk.payload[offset++] << 16;
642 				c.num_frames |= chunk.payload[offset++] <<  8;
643 				c.num_frames |= chunk.payload[offset++] <<  0;
644 
645 				c.num_plays |= chunk.payload[offset++] << 24;
646 				c.num_plays |= chunk.payload[offset++] << 16;
647 				c.num_plays |= chunk.payload[offset++] <<  8;
648 				c.num_plays |= chunk.payload[offset++] <<  0;
649 
650 				assert(offset == chunk.payload.length);
651 
652 				obj.acc = c;
653 				obj.frames = new ApngFrame[](c.num_frames);
654 
655 				seenacTL = true;
656 			break;
657 			case "fcTL":
658 				FrameControlChunk c;
659 				int offset = 0;
660 
661 				seenFctl = true;
662 
663 				c.sequence_number |= chunk.payload[offset++] << 24;
664 				c.sequence_number |= chunk.payload[offset++] << 16;
665 				c.sequence_number |= chunk.payload[offset++] <<  8;
666 				c.sequence_number |= chunk.payload[offset++] <<  0;
667 
668 				c.width |= chunk.payload[offset++] << 24;
669 				c.width |= chunk.payload[offset++] << 16;
670 				c.width |= chunk.payload[offset++] <<  8;
671 				c.width |= chunk.payload[offset++] <<  0;
672 
673 				c.height |= chunk.payload[offset++] << 24;
674 				c.height |= chunk.payload[offset++] << 16;
675 				c.height |= chunk.payload[offset++] <<  8;
676 				c.height |= chunk.payload[offset++] <<  0;
677 
678 				c.x_offset |= chunk.payload[offset++] << 24;
679 				c.x_offset |= chunk.payload[offset++] << 16;
680 				c.x_offset |= chunk.payload[offset++] <<  8;
681 				c.x_offset |= chunk.payload[offset++] <<  0;
682 
683 				c.y_offset |= chunk.payload[offset++] << 24;
684 				c.y_offset |= chunk.payload[offset++] << 16;
685 				c.y_offset |= chunk.payload[offset++] <<  8;
686 				c.y_offset |= chunk.payload[offset++] <<  0;
687 
688 				c.delay_num |= chunk.payload[offset++] <<  8;
689 				c.delay_num |= chunk.payload[offset++] <<  0;
690 
691 				c.delay_den |= chunk.payload[offset++] <<  8;
692 				c.delay_den |= chunk.payload[offset++] <<  0;
693 
694 				c.dispose_op = cast(APNG_DISPOSE_OP) chunk.payload[offset++];
695 				c.blend_op = cast(APNG_BLEND_OP) chunk.payload[offset++];
696 
697 				assert(offset == chunk.payload.length);
698 
699 				import std.conv;
700 				if(expectedSequenceNumber != c.sequence_number)
701 					throw new Exception("malformed apng file expected fcTL seq " ~ to!string(expectedSequenceNumber) ~ " got " ~ to!string(c.sequence_number));
702 
703 				expectedSequenceNumber++;
704 
705 
706 				if(obj.frames[frameNumber] is null)
707 					obj.frames[frameNumber] = new ApngFrame(obj);
708 				obj.frames[frameNumber].frameControlChunk = c;
709 
710 				frameNumber++;
711 			break;
712 			case "fdAT":
713 				uint sequence_number;
714 				int offset;
715 
716 				sequence_number |= chunk.payload[offset++] << 24;
717 				sequence_number |= chunk.payload[offset++] << 16;
718 				sequence_number |= chunk.payload[offset++] <<  8;
719 				sequence_number |= chunk.payload[offset++] <<  0;
720 
721 				import std.conv;
722 				if(expectedSequenceNumber != sequence_number)
723 					throw new Exception("malformed apng file expected fdAT seq " ~ to!string(expectedSequenceNumber) ~ " got " ~ to!string(sequence_number));
724 
725 				expectedSequenceNumber++;
726 
727 				// and the rest of it is a datastream...
728 				obj.frames[frameNumber - 1].compressedDatastream ~= chunk.payload[offset .. $];
729 			break;
730 			default:
731 				obj.handleOtherChunkWhenLoading(chunk);
732 		}
733 
734 	}
735 
736 	return obj;
737 }
738 
739 
740 /++
741 	It takes the apng file and feeds the file data to your `sink` delegate, the given file,
742 	or simply returns it as an in-memory array.
743 +/
744 void writeApngToData(ApngAnimation apng, scope void delegate(in ubyte[] data) sink) {
745 
746 	apng.resyncData();
747 
748 	PNG* p = blankPNG(apng.header);
749 	if(apng.palette.length)
750 		p.replacePalette(apng.palette);
751 
752 	// I want acTL first, then frames, then idat last.
753 
754 	ubyte[128] buffer;
755 
756 	p.chunks ~= *(Chunk.create("acTL", apng.acc.toChunkPayload(buffer[]).dup));
757 
758 	// then IDAT is required
759 	// FIXME: it might be better to just legit use the first frame but meh gotta check size and stuff too
760 	auto render = apng.renderer();
761 	render.nextFrame();
762 	auto data = render.buffer.imageData.bytes;
763 	addImageDatastreamToPng(data, p, false);
764 
765 	// then the frames
766 	int sequenceNumber = 0;
767 	foreach(frame; apng.frames) {
768 		p.chunks ~= *(Chunk.create("fcTL", frame.frameControlChunk.toChunkPayload(sequenceNumber++, buffer[]).dup));
769 		// fdAT
770 
771 		import std.zlib;
772 
773 		size_t bytesPerLine;
774 		switch(apng.header.type) {
775 			case 0:
776 				// FIXME: < 8 depth not supported here but should be
777 				bytesPerLine = cast(size_t) frame.frameControlChunk.width * 1 * apng.header.depth / 8;
778 			break;
779 			case 2:
780 				bytesPerLine = cast(size_t) frame.frameControlChunk.width * 3 * apng.header.depth / 8;
781 			break;
782 			case 3:
783 				bytesPerLine = cast(size_t) frame.frameControlChunk.width * 1 * apng.header.depth / 8;
784 			break;
785 			case 4:
786 				// FIXME: < 8 depth not supported here but should be
787 				bytesPerLine = cast(size_t) frame.frameControlChunk.width * 2 * apng.header.depth / 8;
788 			break;
789 			case 6:
790 				bytesPerLine = cast(size_t) frame.frameControlChunk.width * 4 * apng.header.depth / 8;
791 			break;
792 			default: assert(0);
793 
794 		}
795 
796 		Chunk dat;
797 		dat.type = ['f', 'd', 'A', 'T'];
798 		size_t pos = 0;
799 
800 		const(ubyte)[] output;
801 
802 		frame.populateData();
803 
804 		while(pos+bytesPerLine <= frame.data.length) {
805 			output ~= 0;
806 			output ~= frame.data[pos..pos+bytesPerLine];
807 			pos += bytesPerLine;
808 		}
809 
810 		auto com = cast(ubyte[]) compress(output);
811 		dat.size = cast(int) com.length + 4;
812 
813 		buffer[0] = (sequenceNumber >> 24) & 0xff;
814 		buffer[1] = (sequenceNumber >> 16) & 0xff;
815 		buffer[2] = (sequenceNumber >>  8) & 0xff;
816 		buffer[3] = (sequenceNumber >>  0) & 0xff;
817 
818 		sequenceNumber++;
819 
820 
821 		dat.payload = buffer[0 .. 4] ~ com;
822 		dat.checksum = crc("fdAT", dat.payload);
823 
824 		p.chunks ~= dat;
825 	}
826 
827 	{
828 		Chunk c;
829 
830 		c.size = 0;
831 		c.type = ['I', 'E', 'N', 'D'];
832 		c.checksum = crc("IEND", c.payload);
833 		p.chunks ~= c;
834 	}
835 
836 	sink(writePng(p));
837 }
838 
839 /// ditto
840 void writeApngToFile(ApngAnimation apng, string filename) {
841 	import std.stdio;
842 	auto file = File(filename, "wb");
843 	writeApngToData(apng, delegate(in ubyte[] data) {
844 		file.rawWrite(data);
845 	});
846 }
847 
848 /// ditto
849 ubyte[] getApngBytes(ApngAnimation apng) {
850 	ubyte[] ret;
851 	writeApngToData(apng, (in ubyte[] data) { ret ~= data; });
852 	return ret;
853 }