forked from Viniixd/Remastered-AssetsEditor
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathImageExporter.cs
More file actions
399 lines (341 loc) · 20.6 KB
/
ImageExporter.cs
File metadata and controls
399 lines (341 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using Tibia.Protobuf.Appearances;
using System;
using ImageMagick;
namespace Assets_Editor;
/// <summary>
/// This class is responsible for exporting individual assets as GIFs of PNGs
/// </summary>
public static class ImageExporter {
/// <summary>
/// Reads the duration of the animation. Defaults to 30ms if no duration was found.
/// </summary>
/// <param name="frameGroup">framegroup to be checked</param>
/// <param name="index">frame index to be checked for duration</param>
/// <returns>Frame duration in milliseconds</returns>
private static uint SafeGetFrameDuration(FrameGroup frameGroup, int index) {
var animation = frameGroup.SpriteInfo.Animation;
if (animation == null || animation.SpritePhase.Count == 0) {
return 30;
}
if (index >= animation.SpritePhase.Count) {
index = animation.SpritePhase.Count - 1;
}
return Math.Max(30, frameGroup.SpriteInfo.Animation.SpritePhase[index].DurationMin);
}
/// <summary>
/// Reads the highest frame index in the animation. Defaults to 0 (as indexing is from 0) if framegroup has no animation.
/// </summary>
/// <param name="frameGroup">framegroup to be checked</param>
/// <returns>highest frame index</returns>
private static uint SafeGetAnimationFrameCount(FrameGroup frameGroup) {
var animation = frameGroup.SpriteInfo.Animation;
if (animation == null) {
return 0;
}
return (uint)animation.SpritePhase.Count;
}
/// <summary>
/// Helper function for item saving
/// </summary>
/// <param name="exportPath">path where the item is supposed to be exported</param>
/// <param name="appearance">item to be exported</param>
private static void SaveItemAsGIFBasic(string exportPath, Appearance appearance) {
// framegroup id - always 2
foreach (var frameGroup in appearance.FrameGroup) {
var spriteInfo = frameGroup.SpriteInfo;
// width and height correspond item state
// examples:
// currently held fluid 0 - empty, 1 - water
// current stack state: 0 - 1 coin, 4 - 5 coins, 5 - 10 coins, 6 - 25 coins)
for (int width = 0; width < spriteInfo.PatternWidth; ++width) {
for (int height = 0; height < spriteInfo.PatternHeight; ++height) {
// depth correspond item appearance on [Z % layers == layer] floors
// example: zaoan roof varies in shade depending on Z coordinate
for (int depth = 0; depth < spriteInfo.PatternDepth; ++depth) {
// frames in a GIF
List<(Bitmap, uint)> frames = [];
// item animation
for (int frame = 0; frame <= SafeGetAnimationFrameCount(frameGroup); ++frame) {
// layer count - always 1
for (int layers = 0; layers < spriteInfo.Layers; ++layers) {
try {
// the position of the sprite in the spriteinfo array of sprite ids
int spriteIndex = DatEditor.GetSpriteIndex(frameGroup, layers, width, height, depth, frame);
// create a bitmap
var imageFrame = Utils.BitmapToBitmapImage(MainWindow.getSpriteStream((int)spriteInfo.SpriteId[spriteIndex]));
Bitmap bitmap = new(imageFrame.StreamSource);
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(transparentBitmap)) {
g.Clear(Color.Transparent);
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
bitmap.Dispose();
frames.Add((transparentBitmap, 100));
} catch (Exception ex) {
MainWindow.Log($"Failed to save image {frameGroup.Id} {height} {depth} {width} {frame} {layers} for object {appearance.Id}: {ex.Message}");
}
}
}
// 2d pattern to subtype index conversion
int subTypeIndex = height * (int)spriteInfo.PatternWidth + width;
// filename
// add _depth only when the item has multiple depth layers
string fileName = $"{subTypeIndex}{(depth > 0 ? ("_" + depth) : "")}.gif";
// write a GIF file
SaveFramesAsGIF(frames, Path.Combine(exportPath, fileName));
}
}
}
}
}
/// <summary>
/// Helper function for item saving
/// </summary>
/// <param name="exportPath">path where the item is supposed to be exported</param>
/// <param name="appearance">item to be exported</param>
private static void SaveItemAsGIFLoot(string exportPath, Appearance appearance) {
// framegroup id - always 2
foreach (var frameGroup in appearance.FrameGroup) {
var spriteInfo = frameGroup.SpriteInfo;
// frames in a GIF
List<(Bitmap, uint)> frames = [];
int currentFrame = 0;
uint frameCount = SafeGetAnimationFrameCount(frameGroup);
// if the stackable item is animated, play the animation but keep it within 200ms
// before jumping to next state
uint currentSequenceDuration = 0;
uint timePerPhase = 500; // this is the framerate stackable items have on wikis
// layer count - always 1
for (int layers = 0; layers < spriteInfo.Layers; ++layers) {
// depth correspond item appearance on [Z % layers == layer] floors
// example: zaoan roof varies in shade depending on Z coordinate
for (int depth = 0; depth < spriteInfo.PatternDepth; ++depth) {
// filename
// loot.gif - this is a preview for all states of this item
// loot_depth.gif only when the item has multiple depth layers
string fileName = $"loot{(depth > 0 ? ("_" + depth) : "")}.gif";
// width and height correspond item state
// examples:
// currently held fluid 0 - empty, 1 - water
// current stack state: 0 - 1 coin, 4 - 5 coins, 5 - 10 coins, 6 - 25 coins)
// HEIGHT HAS TO BE FIRST HERE, OTHERWISE THE FRAMES WILL ANIMATE IN WRONG ORDER
for (int height = 0; height < spriteInfo.PatternHeight; ++height) {
for (int width = 0; width < spriteInfo.PatternWidth; ++width) {
currentSequenceDuration = 0;
if (frameCount > 1) {
// stackable/fluid + animated
try {
while (currentSequenceDuration < timePerPhase) {
// the position of the sprite in the spriteinfo array of sprite ids
int spriteIndex = DatEditor.GetSpriteIndex(frameGroup, layers, width, height, depth, currentFrame % (int)frameCount);
// create a bitmap
var imageFrame = Utils.BitmapToBitmapImage(MainWindow.getSpriteStream((int)spriteInfo.SpriteId[spriteIndex]));
Bitmap bitmap = new(imageFrame.StreamSource);
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(transparentBitmap)) {
g.Clear(Color.Transparent);
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
bitmap.Dispose();
uint frameDuration = Math.Min(timePerPhase - currentSequenceDuration, SafeGetFrameDuration(frameGroup, spriteIndex));
currentSequenceDuration += frameDuration;
frames.Add((transparentBitmap, frameDuration));
// move to next frame
++currentFrame;
}
} catch (Exception ex) {
MainWindow.Log($"Failed to save image {frameGroup.Id} {height} {depth} {width} {currentFrame % (int) frameCount} {layers} for object {appearance.Id}: {ex.Message}");
}
} else {
// just stackable/fluid
for (int frame = 0; frame <= SafeGetAnimationFrameCount(frameGroup); ++frame) {
try {
// the position of the sprite in the spriteinfo array of sprite ids
int spriteIndex = DatEditor.GetSpriteIndex(frameGroup, layers, width, height, depth, frame);
// create a bitmap
var imageFrame = Utils.BitmapToBitmapImage(MainWindow.getSpriteStream((int)spriteInfo.SpriteId[spriteIndex]));
Bitmap bitmap = new(imageFrame.StreamSource);
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(transparentBitmap)) {
g.Clear(Color.Transparent);
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
bitmap.Dispose();
frames.Add((transparentBitmap, timePerPhase));
} catch (Exception ex) {
MainWindow.Log($"Failed to save image {frameGroup.Id} {height} {depth} {width} {frame} {layers} for object {appearance.Id}: {ex.Message}");
}
}
}
}
}
// write a GIF file
SaveFramesAsGIF(frames, Path.Combine(exportPath, fileName));
}
}
}
}
/// <summary>
/// Creates gifs for various item states in /clientid/ directory
/// </summary>
/// <param name="exportPath">path where export action is occurring</param>
/// <param name="appearance">item to be exported</param>
public static void SaveItemAsGIF(string exportPath, Appearance appearance) {
string itemPath = Path.Combine(exportPath, Convert.ToString(appearance.Id));
Directory.CreateDirectory(itemPath);
SaveItemAsGIFBasic(itemPath, appearance);
// if stackable or liquid, save it as an animation that loops through its all states
if (appearance.Flags.Cumulative || appearance.Flags.Liquidcontainer || appearance.Flags.Liquidpool) {
SaveItemAsGIFLoot(itemPath, appearance);
}
}
/// <summary>
/// Saves outfits in a format similar to Gesior's outfit.php
/// Only difference is that it has 0 for idle and 1 for movement animation at the front of file name
/// </summary>
/// <param name="exportPath">path where export action is occurring</param>
/// <param name="appearance">outfit to be exported</param>
public static void SaveOutfitAsImages(string exportPath, Appearance appearance) {
string outfitPath = Path.Combine(exportPath, Convert.ToString(appearance.Id));
Directory.CreateDirectory(outfitPath);
foreach (var frameGroup in appearance.FrameGroup) {
var spriteInfo = frameGroup.SpriteInfo;
for (int height = 0; height < spriteInfo.PatternHeight; ++height) {
for (int depth = 0; depth < spriteInfo.PatternDepth; ++depth) {
for (int width = 0; width < spriteInfo.PatternWidth; ++width) {
for (int frames = 0; frames <= SafeGetAnimationFrameCount(frameGroup); ++frames) {
for (int layers = 0; layers < spriteInfo.Layers; ++layers) {
try {
int index = DatEditor.GetSpriteIndex(frameGroup, layers, width, height, depth, frames);
// set the filename style to n_n_n_n_n_template.png
// indexing from 1 is the standard in Gesior outfit.php
// the difference from outfit.php is that framegroup id is added here at the front
// in order to distinguish between idle (0) and move (1) animation
// removing framegroup id from the front of filename will make the move animation glitchy
// and will make the idle animation show a wrong frame
string fileName = $"{frameGroup.Id}_{height + 1}_{depth + 1}_{width + 1}_{frames + 1}{(layers == 1 ? "_template" : "")}.png";
var bitmapImage = Utils.BitmapToBitmapImage(MainWindow.getSpriteStream((int)spriteInfo.SpriteId[index]));
Bitmap bitmap = new(bitmapImage.StreamSource);
// ensure the bitmap supports transparency
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using Graphics g = Graphics.FromImage(transparentBitmap);
g.Clear(Color.Transparent); // use transparent background
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
transparentBitmap.Save(Path.Combine(outfitPath, fileName), ImageFormat.Png);
transparentBitmap.Dispose();
bitmap.Dispose();
} catch (Exception ex) {
MainWindow.Log($"Failed to save image {frameGroup.Id} {height} {depth} {width} {frames} {layers} for object {appearance.Id}: {ex.Message}");
}
}
}
}
}
}
}
}
/// <summary>
/// Saves magic effects as GIFs
/// </summary>
/// <param name="exportPath">path where export action is occurring</param>
/// <param name="appearance">effect to be exported</param>
public static void SaveEffectAsGIF(string exportPath, Appearance appearance) {
List<(Bitmap, uint)> frames = [];
int layer = 0; // always 0
int depth = 0; // always 0
// taking the animation from (0, 0)
// if you intend to capture all variants of mas frigo
// or other large area effects, you will have to loop over these
// for reference see how items are saved
int height = 0;
int width = 0;
// default frame group
var frameGroup = appearance.FrameGroup[0];
try {
for (int animationFrame = 0; animationFrame < appearance.FrameGroup[0].SpriteInfo.SpriteId.Count; animationFrame++) {
int index = DatEditor.GetSpriteIndex(frameGroup, layer, (int)Math.Min(width, frameGroup.SpriteInfo.PatternWidth - 1), height, depth, animationFrame);
var imageFrame = Utils.BitmapToBitmapImage(MainWindow.getSpriteStream((int)frameGroup.SpriteInfo.SpriteId[index]));
uint frameDuration = SafeGetFrameDuration(frameGroup, index);
using Bitmap bitmap = new(imageFrame.StreamSource);
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(transparentBitmap)) {
g.Clear(Color.Transparent);
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
frames.Add((transparentBitmap, frameDuration));
}
SaveFramesAsGIF(frames, Path.Combine(exportPath, "" + appearance.Id + ".gif"));
} catch {
MainWindow.Log("Invalid animation for sprite " + appearance.Id + ".");
} finally {
// dispose frames if neccessary
foreach (var (frame, _) in frames) {
frame.Dispose();
}
}
}
/// <summary>
/// Saves missile effects as GIFs, rotating them clockwise starting from the north
/// </summary>
/// <param name="exportPath">path where export action is occurring</param>
/// <param name="appearance">missile effect to be exported</param>
public static void SaveMissileAsGIF(string exportPath, Appearance appearance) {
// Frame order for missiles (indexed from 0): 1, 2, 5, 8, 7, 6, 3, 0
// This corresponds to the missile aiming north and rotating clockwise
int[] frameOrder = [1, 2, 5, 8, 7, 6, 3, 0];
List<(Bitmap, uint)> frames = [];
try {
var spriteInfo = appearance.FrameGroup[0].SpriteInfo;
// loop over frames in proper order
foreach (int frameIndex in frameOrder) {
if (frameIndex < spriteInfo.SpriteId.Count) {
var imageFrame = Utils.BitmapToBitmapImage(
MainWindow.getSpriteStream((int)spriteInfo.SpriteId[frameIndex])
);
using Bitmap bitmap = new(imageFrame.StreamSource);
Bitmap transparentBitmap = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb);
using (Graphics g = Graphics.FromImage(transparentBitmap)) {
g.Clear(Color.Transparent);
g.DrawImage(bitmap, 0, 0, bitmap.Width, bitmap.Height);
}
frames.Add((transparentBitmap, 100));
}
}
SaveFramesAsGIF(frames, Path.Combine(exportPath, "" + appearance.Id + ".gif"));
} catch {
MainWindow.Log("Invalid animation for sprite " + appearance.Id + ".");
} finally {
// Dispose of frames if necessary
foreach (var (frame, _) in frames) {
frame.Dispose();
}
}
}
/// <summary>
/// Common function to save as GIF
/// </summary>
/// <param name="frames">animation frames</param>
/// <param name="fullFilePath">file path including file name</param>
public static void SaveFramesAsGIF(List<(Bitmap, uint)> frames, string fullFilePath) {
if (frames.Count == 0) {
return;
}
var collection = new MagickImageCollection();
foreach (var (bitmap, duration) in frames) {
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png); // Use PNG to preserve transparency
stream.Position = 0; // Reset stream position before reading
MagickImage magickImage = new(stream);
magickImage.AnimationDelay = duration / 10; // in 1/100th second
magickImage.GifDisposeMethod = GifDisposeMethod.Background; // use REPLACE instead of COMBINE for next frame
collection.Add(magickImage);
}
collection[0].AnimationIterations = 0; // loop forever
collection.Optimize();
collection.Write(fullFilePath);
}
}