Fix sticker capture and rendering

This commit is contained in:
sevenhill
2026-07-06 13:37:07 +03:00
parent 30361370da
commit e50b99bcd2
3 changed files with 223 additions and 24 deletions
@@ -2506,6 +2506,7 @@ private fun replyPreviewBody(message: MessageDto): String {
val attachment = message.attachments.firstOrNull() ?: return "Message"
return when {
attachment.isEmojiAttachment() -> "Emoji"
attachment.isStickerAttachment() -> "Sticker"
attachment.isImageAttachment() -> "Photo"
attachment.isVideoAttachment() -> "Video"
attachment.isVoiceAttachment() -> "Voice message"
@@ -2689,6 +2690,13 @@ private fun AttachmentView(
cachedImagePath,
vm
)
attachment.isStickerAttachment() -> StickerAttachment(
attachment,
url,
session.accessToken,
cachedImagePath,
vm
)
attachment.isImageAttachment() -> ImageAttachment(
attachment,
url,
@@ -2704,6 +2712,47 @@ private fun AttachmentView(
}
}
@Composable
private fun StickerAttachment(
attachment: AttachmentDto,
url: String,
token: String,
cachedImagePath: String?,
vm: QMaxViewModel
) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
Box(
modifier = Modifier
.size(156.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { vm.openImage(cachedImagePath ?: url, listOf(cachedImagePath ?: url)) },
contentAlignment = Alignment.Center
) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
}
}
@Composable
private fun EmojiAttachment(
attachment: AttachmentDto,
@@ -3685,7 +3734,7 @@ private fun imageRequest(url: String, token: String): ImageRequest {
}
private fun AttachmentDto.isImageAttachment(): Boolean {
if (isEmojiAttachment()) {
if (isEmojiAttachment() || isStickerAttachment()) {
return false
}
return kind.equals("Image", true) ||
@@ -3700,6 +3749,12 @@ private fun AttachmentDto.isEmojiAttachment(): Boolean {
kind.equals("Emoji", true)
}
private fun AttachmentDto.isStickerAttachment(): Boolean {
val normalizedFileName = fileName.lowercase(Locale.ROOT)
return kind.equals("Sticker", true) ||
normalizedFileName.startsWith("max-sticker-")
}
private fun AttachmentDto.isContactAttachment(): Boolean {
return kind.equals("Contact", true) ||
contentType.contains("vcard", ignoreCase = true) ||
@@ -81,6 +81,7 @@ class AttachmentDiskCache(context: Context) {
attachment.downloadPath,
attachment.fileName,
attachment.contentType,
attachment.kind,
attachment.fileSizeBytes.toString()
).joinToString("|")
).take(40)
+166 -23
View File
@@ -1190,9 +1190,135 @@ async function downloadDomAudioThroughPlay(p, request) {
async function downloadDomStickerThroughScreenshot(p, request) {
const testId = String(request?.testId || "").trim();
const timeText = String(request?.timeText || "").trim();
const fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]");
const kindLabel = String(request?.kindLabel || "MAX sticker canvas");
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const visualHandleFor = async (handle) => {
const visual = await handle.evaluateHandle((node) => {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return null;
const candidates = [
...(element.matches("canvas,img,image,svg") ? [element] : []),
...Array.from(element.querySelectorAll("canvas,img,image,svg,[style*='background' i]"))
];
const scored = candidates
.map((candidate) => {
const rect = candidate.getBoundingClientRect();
const style = window.getComputedStyle(candidate);
if (style.visibility === "hidden" ||
style.display === "none" ||
rect.width <= 0 ||
rect.height <= 0) {
return null;
}
const semantic = [
candidate.getAttribute?.("data-testid"),
candidate.getAttribute?.("aria-label"),
candidate.getAttribute?.("title"),
candidate.className?.baseVal || candidate.className,
candidate.tagName
].filter(Boolean).join(" ").toLowerCase();
let score = rect.width * rect.height;
if (candidate.matches("canvas,img,image,svg")) score += 100000;
if (/sticker|emoji|lottie|emoticon|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438/.test(semantic)) score += 50000;
return { candidate, score };
})
.filter(Boolean)
.sort((a, b) => b.score - a.score);
return scored[0]?.candidate || element;
}).catch(() => null);
const element = visual?.asElement?.();
if (element) {
return element;
}
await visual?.dispose?.().catch(() => {});
return handle;
};
const resolveVisualHandles = async (handles) => {
const resolved = [];
for (const handle of handles) {
const visual = await visualHandleFor(handle);
if (visual !== handle) {
await handle.dispose().catch(() => {});
}
resolved.push(visual);
}
return resolved;
};
const captureCanvasPng = async (handle) => {
const result = await handle.evaluate(async (node) => {
const element = node instanceof Element ? node : node?.parentElement;
const canvas = element?.matches?.("canvas")
? element
: element?.querySelector?.("canvas");
if (!(canvas instanceof HTMLCanvasElement)) {
return { hasCanvas: false, blocked: false, dataUrl: null };
}
const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const hasPaintedPixels = () => {
const width = canvas.width;
const height = canvas.height;
if (width <= 0 || height <= 0) return false;
let data;
try {
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return true;
data = context.getImageData(0, 0, width, height).data;
} catch (_error) {
return null;
}
const totalPixels = width * height;
const step = Math.max(1, Math.floor(totalPixels / 12000));
let painted = 0;
for (let pixel = 0; pixel < totalPixels; pixel += step) {
if (data[pixel * 4 + 3] > 8) {
painted++;
}
}
return painted >= Math.max(6, Math.ceil(totalPixels / step * 0.01));
};
for (let attempt = 0; attempt < 10; attempt++) {
const painted = hasPaintedPixels();
if (painted === true) {
try {
return { hasCanvas: true, blocked: false, dataUrl: canvas.toDataURL("image/png") };
} catch (_error) {
return { hasCanvas: true, blocked: true, dataUrl: null };
}
}
if (painted === null) {
try {
return { hasCanvas: true, blocked: true, dataUrl: canvas.toDataURL("image/png") };
} catch (_error) {
return { hasCanvas: true, blocked: true, dataUrl: null };
}
}
await sleep(200);
}
return { hasCanvas: true, blocked: false, dataUrl: null };
}).catch(() => ({ hasCanvas: false, blocked: false, dataUrl: null }));
const dataUrl = String(result?.dataUrl || "");
const prefix = "data:image/png;base64,";
return {
hasCanvas: Boolean(result?.hasCanvas),
blocked: Boolean(result?.blocked),
buffer: dataUrl.startsWith(prefix) ? Buffer.from(dataUrl.slice(prefix.length), "base64") : null
};
};
const screenshotCandidate = async (handles, requestedIndex) => {
const candidates = [];
for (const handle of handles) {
@@ -1234,11 +1360,16 @@ async function downloadDomStickerThroughScreenshot(p, request) {
.join(" ");
const timeMatches = requestedTimeText &&
(wrapperText.includes(requestedTimeText) || metaText.includes(requestedTimeText));
const semanticRoot = element.closest("[data-testid^='sticker-'], [data-testid*='emoji' i], [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]") || element;
const semantic = [
element.getAttribute("data-testid"),
element.getAttribute("aria-label"),
element.getAttribute("title"),
element.className,
semanticRoot.getAttribute?.("data-testid"),
semanticRoot.getAttribute?.("aria-label"),
semanticRoot.getAttribute?.("title"),
semanticRoot.className,
wrapper.className,
wrapperText
].filter(Boolean).join(" ").toLowerCase();
@@ -1328,47 +1459,59 @@ async function downloadDomStickerThroughScreenshot(p, request) {
const box = await target.handle.boundingBox().catch(() => null);
return box && box.width > 0 && box.height > 0 ? box : metaBox;
};
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
let buffer;
if (request?.acceptSmall) {
buffer = await screenshotClip(await currentBoxOrMeta());
if (!buffer) {
throw new Error(`${kindLabel} clip was not visible.`);
try {
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
const canvasCapture = await captureCanvasPng(target.handle);
if (canvasCapture.buffer) {
return { contentType: "image/png", buffer: canvasCapture.buffer };
}
} else {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
if (canvasCapture.hasCanvas && !canvasCapture.blocked) {
throw new Error(`${kindLabel} canvas is blank.`);
}
let buffer;
if (request?.acceptSmall) {
buffer = await screenshotClip(await currentBoxOrMeta());
if (!buffer) {
throw new Error(`${kindLabel} clip was not visible.`);
}
} else {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
buffer = await screenshotClip(await currentBoxOrMeta());
if (!buffer) {
throw error;
}
}
}
if (!buffer) {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
throw error;
}
}
}
if (!buffer) {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
throw error;
return { contentType: "image/png", buffer };
} finally {
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
}
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
return { contentType: "image/png", buffer };
};
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
if (testId) {
const handles = await p.$$(selectorForTestId(testId));
const result = await screenshotCandidate(handles, 0);
const result = await screenshotCandidate(await resolveVisualHandles(handles), 0);
if (result) {
return result;
}
}
const handles = await p.$$(fallbackSelector);
const result = await screenshotCandidate(handles, index);
const result = await screenshotCandidate(await resolveVisualHandles(handles), index);
if (!result) {
throw new Error(`${kindLabel} was not found.`);
}