Fix sticker capture and rendering
This commit is contained in:
@@ -2506,6 +2506,7 @@ private fun replyPreviewBody(message: MessageDto): String {
|
|||||||
val attachment = message.attachments.firstOrNull() ?: return "Message"
|
val attachment = message.attachments.firstOrNull() ?: return "Message"
|
||||||
return when {
|
return when {
|
||||||
attachment.isEmojiAttachment() -> "Emoji"
|
attachment.isEmojiAttachment() -> "Emoji"
|
||||||
|
attachment.isStickerAttachment() -> "Sticker"
|
||||||
attachment.isImageAttachment() -> "Photo"
|
attachment.isImageAttachment() -> "Photo"
|
||||||
attachment.isVideoAttachment() -> "Video"
|
attachment.isVideoAttachment() -> "Video"
|
||||||
attachment.isVoiceAttachment() -> "Voice message"
|
attachment.isVoiceAttachment() -> "Voice message"
|
||||||
@@ -2689,6 +2690,13 @@ private fun AttachmentView(
|
|||||||
cachedImagePath,
|
cachedImagePath,
|
||||||
vm
|
vm
|
||||||
)
|
)
|
||||||
|
attachment.isStickerAttachment() -> StickerAttachment(
|
||||||
|
attachment,
|
||||||
|
url,
|
||||||
|
session.accessToken,
|
||||||
|
cachedImagePath,
|
||||||
|
vm
|
||||||
|
)
|
||||||
attachment.isImageAttachment() -> ImageAttachment(
|
attachment.isImageAttachment() -> ImageAttachment(
|
||||||
attachment,
|
attachment,
|
||||||
url,
|
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
|
@Composable
|
||||||
private fun EmojiAttachment(
|
private fun EmojiAttachment(
|
||||||
attachment: AttachmentDto,
|
attachment: AttachmentDto,
|
||||||
@@ -3685,7 +3734,7 @@ private fun imageRequest(url: String, token: String): ImageRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun AttachmentDto.isImageAttachment(): Boolean {
|
private fun AttachmentDto.isImageAttachment(): Boolean {
|
||||||
if (isEmojiAttachment()) {
|
if (isEmojiAttachment() || isStickerAttachment()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return kind.equals("Image", true) ||
|
return kind.equals("Image", true) ||
|
||||||
@@ -3700,6 +3749,12 @@ private fun AttachmentDto.isEmojiAttachment(): Boolean {
|
|||||||
kind.equals("Emoji", true)
|
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 {
|
private fun AttachmentDto.isContactAttachment(): Boolean {
|
||||||
return kind.equals("Contact", true) ||
|
return kind.equals("Contact", true) ||
|
||||||
contentType.contains("vcard", ignoreCase = true) ||
|
contentType.contains("vcard", ignoreCase = true) ||
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class AttachmentDiskCache(context: Context) {
|
|||||||
attachment.downloadPath,
|
attachment.downloadPath,
|
||||||
attachment.fileName,
|
attachment.fileName,
|
||||||
attachment.contentType,
|
attachment.contentType,
|
||||||
|
attachment.kind,
|
||||||
attachment.fileSizeBytes.toString()
|
attachment.fileSizeBytes.toString()
|
||||||
).joinToString("|")
|
).joinToString("|")
|
||||||
).take(40)
|
).take(40)
|
||||||
|
|||||||
+147
-4
@@ -1190,9 +1190,135 @@ async function downloadDomAudioThroughPlay(p, request) {
|
|||||||
async function downloadDomStickerThroughScreenshot(p, request) {
|
async function downloadDomStickerThroughScreenshot(p, request) {
|
||||||
const testId = String(request?.testId || "").trim();
|
const testId = String(request?.testId || "").trim();
|
||||||
const timeText = String(request?.timeText || "").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 kindLabel = String(request?.kindLabel || "MAX sticker canvas");
|
||||||
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
|
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 screenshotCandidate = async (handles, requestedIndex) => {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const handle of handles) {
|
for (const handle of handles) {
|
||||||
@@ -1234,11 +1360,16 @@ async function downloadDomStickerThroughScreenshot(p, request) {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
const timeMatches = requestedTimeText &&
|
const timeMatches = requestedTimeText &&
|
||||||
(wrapperText.includes(requestedTimeText) || metaText.includes(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 = [
|
const semantic = [
|
||||||
element.getAttribute("data-testid"),
|
element.getAttribute("data-testid"),
|
||||||
element.getAttribute("aria-label"),
|
element.getAttribute("aria-label"),
|
||||||
element.getAttribute("title"),
|
element.getAttribute("title"),
|
||||||
element.className,
|
element.className,
|
||||||
|
semanticRoot.getAttribute?.("data-testid"),
|
||||||
|
semanticRoot.getAttribute?.("aria-label"),
|
||||||
|
semanticRoot.getAttribute?.("title"),
|
||||||
|
semanticRoot.className,
|
||||||
wrapper.className,
|
wrapper.className,
|
||||||
wrapperText
|
wrapperText
|
||||||
].filter(Boolean).join(" ").toLowerCase();
|
].filter(Boolean).join(" ").toLowerCase();
|
||||||
@@ -1328,7 +1459,17 @@ async function downloadDomStickerThroughScreenshot(p, request) {
|
|||||||
const box = await target.handle.boundingBox().catch(() => null);
|
const box = await target.handle.boundingBox().catch(() => null);
|
||||||
return box && box.width > 0 && box.height > 0 ? box : metaBox;
|
return box && box.width > 0 && box.height > 0 ? box : metaBox;
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
||||||
|
const canvasCapture = await captureCanvasPng(target.handle);
|
||||||
|
if (canvasCapture.buffer) {
|
||||||
|
return { contentType: "image/png", buffer: canvasCapture.buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvasCapture.hasCanvas && !canvasCapture.blocked) {
|
||||||
|
throw new Error(`${kindLabel} canvas is blank.`);
|
||||||
|
}
|
||||||
|
|
||||||
let buffer;
|
let buffer;
|
||||||
if (request?.acceptSmall) {
|
if (request?.acceptSmall) {
|
||||||
buffer = await screenshotClip(await currentBoxOrMeta());
|
buffer = await screenshotClip(await currentBoxOrMeta());
|
||||||
@@ -1352,23 +1493,25 @@ async function downloadDomStickerThroughScreenshot(p, request) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { contentType: "image/png", buffer };
|
||||||
|
} finally {
|
||||||
for (const item of candidates) {
|
for (const item of candidates) {
|
||||||
await item.handle.dispose().catch(() => {});
|
await item.handle.dispose().catch(() => {});
|
||||||
}
|
}
|
||||||
return { contentType: "image/png", buffer };
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
|
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
|
||||||
if (testId) {
|
if (testId) {
|
||||||
const handles = await p.$$(selectorForTestId(testId));
|
const handles = await p.$$(selectorForTestId(testId));
|
||||||
const result = await screenshotCandidate(handles, 0);
|
const result = await screenshotCandidate(await resolveVisualHandles(handles), 0);
|
||||||
if (result) {
|
if (result) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handles = await p.$$(fallbackSelector);
|
const handles = await p.$$(fallbackSelector);
|
||||||
const result = await screenshotCandidate(handles, index);
|
const result = await screenshotCandidate(await resolveVisualHandles(handles), index);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error(`${kindLabel} was not found.`);
|
throw new Error(`${kindLabel} was not found.`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user