1import type { Group as KonvaGroup } from "konva/lib/Group"
2import type { Image as KonvaImageNode } from "konva/lib/shapes/Image"
3import type { Stage as KonvaStage } from "konva/lib/Stage"
4
5import { ensureFontsReady } from "@/features/editor/canvasFonts"
6import { applyEffect } from "@/features/editor/filters"
7import type { EffectParams } from "@/features/editor/presets"
8import type { LiveKind } from "@/lib/schemas"
9
10/* FR-11: ekspor strip 600 x 1800 px sebagai PNG di bawah 2 MB, tanpa metadata
11 lokasi (kanvas tidak pernah menulis EXIF). Ekspor dilakukan dengan mengubah
12 ukuran panggung di tempat, karena `Stage.toDataURL` membuat kanvas baru dan
13 tidak menyentuh kanvas yang terlihat. */
14
15export const PNG_BYTE_LIMIT = 2_097_152
16/* Dipakai hanya di jalur kedua, saat PNG masih melebihi batas. */
17const SLOT_JPEG_QUALITY = 0.8
18
19/** FR-11: nama berkas `snapme-YYYYMMDD-HHmm.png`. M7 (SH-03): versi hidup
20 * memakai akhiran jenis rekamannya, jadi polanya satu. */
21export function formatStripFilename(
22 date: Date,
23 ext: "png" | LiveKind = "png"
24): string {
25 const pad = (value: number) => String(value).padStart(2, "0")
26 return `snapme-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
27 date.getDate()
28 )}-${pad(date.getHours())}${pad(date.getMinutes())}.${ext}`
29}
30
31function pngByteLength(dataUrl: string): number {
32 return atob(dataUrl.slice(dataUrl.indexOf(",") + 1)).length
33}
34
35function loadImage(src: string): Promise<HTMLImageElement> {
36 const { promise, resolve, reject } = Promise.withResolvers<HTMLImageElement>()
37 const image = new window.Image()
38 image.onload = () => resolve(image)
39 image.onerror = () => reject(new Error("Gambar tidak bisa dimuat"))
40 image.src = src
41 return promise
42}
43
44/* Foto asli punya derau yang tidak bisa dipadatkan PNG. Menggambar ulang tiap
45 slot lewat JPEG 0,8 menghaluskannya, dan PNG-nya ikut mengecil. */
46async function recompressSlots(
47 group: KonvaGroup,
48 effect: EffectParams
49): Promise<void> {
50 for (const node of group.find(".slot-photo")) {
51 const photo = node as KonvaImageNode
52 const source = photo.image()
53 if (source === undefined) {
54 continue
55 }
56
57 const canvas = document.createElement("canvas")
58 canvas.width = Math.round(photo.width())
59 canvas.height = Math.round(photo.height())
60 const context = canvas.getContext("2d")
61 if (context === null) {
62 continue
63 }
64
65 context.drawImage(source, 0, 0, canvas.width, canvas.height)
66 photo.image(await loadImage(canvas.toDataURL("image/jpeg", SLOT_JPEG_QUALITY)))
67 /* Filter bekerja di atas kanvas cache, jadi cache harus dibuat ulang. */
68 applyEffect(photo, effect)
69 }
70}
71
72interface StripExportOptions {
73 canvas: { width: number; height: number }
74 effect: EffectParams
75}
76
77export async function exportStrip(
78 stage: KonvaStage,
79 group: KonvaGroup,
80 options: StripExportOptions
81): Promise<string> {
82 await ensureFontsReady()
83
84 /* Transformer bukan bagian dari strip. */
85 const overlay = stage.find(".editor-overlay")
86 for (const node of overlay) {
87 node.hide()
88 }
89
90 const previous = {
91 width: stage.width(),
92 height: stage.height(),
93 scaleX: group.scaleX(),
94 scaleY: group.scaleY(),
95 }
96
97 function drawAtExportSize(): string {
98 stage.size({ width: options.canvas.width, height: options.canvas.height })
99 group.scale({ x: 1, y: 1 })
100 return stage.toDataURL({ mimeType: "image/png", pixelRatio: 1 })
101 }
102
103 let dataUrl = drawAtExportSize()
104 if (pngByteLength(dataUrl) > PNG_BYTE_LIMIT) {
105 await recompressSlots(group, options.effect)
106 dataUrl = drawAtExportSize()
107 }
108
109 /* Ukuran dan skala dikembalikan sendiri, bukan lewat render ulang: prop
110 React-nya tidak berubah, jadi react-konva tidak akan memasangnya lagi. */
111 stage.size({ width: previous.width, height: previous.height })
112 group.scale({ x: previous.scaleX, y: previous.scaleY })
113 for (const node of overlay) {
114 node.show()
115 }
116 stage.batchDraw()
117
118 return dataUrl
119}
120
121/** Unduhan dari blob, dipakai strip PNG dan (M7, SH-03) video versi hidup.
122 * URL objeknya dilepas setelah klik supaya blob besar tidak menahan memori. */
123export function downloadBlob(blob: Blob, filename: string): void {
124 const url = URL.createObjectURL(blob)
125 const anchor = document.createElement("a")
126 anchor.href = url
127 anchor.download = filename
128 document.body.appendChild(anchor)
129 anchor.click()
130 anchor.remove()
131 window.setTimeout(() => URL.revokeObjectURL(url), 10_000)
132}
133
134/** FR-11: unduhan langsung dari perangkat pengguna, tanpa permintaan jaringan. */
135export function downloadStrip(dataUrl: string, filename: string): void {
136 const meta = dataUrl.slice(0, dataUrl.indexOf(","))
137 const mime = meta.slice(meta.indexOf(":") + 1, meta.indexOf(";"))
138 const binary = atob(dataUrl.slice(dataUrl.indexOf(",") + 1))
139 const bytes = new Uint8Array(binary.length)
140 for (let index = 0; index < binary.length; index += 1) {
141 bytes[index] = binary.charCodeAt(index)
142 }
143
144 downloadBlob(new Blob([bytes], { type: mime }), filename)
145}
146