fix: generated alt

This commit is contained in:
Yanlong Wang 2025-03-21 22:43:19 +08:00
parent 2a30fce1cc
commit 3bb731519b
No known key found for this signature in database
GPG Key ID: C0A623C0BADF9F37
8 changed files with 283 additions and 67 deletions

19
package-lock.json generated
View File

@ -17,7 +17,7 @@
"axios": "^1.3.3",
"bcrypt": "^5.1.0",
"busboy": "^1.6.0",
"civkit": "^0.9.0-f7b0ca7",
"civkit": "^0.9.0-848ef4e",
"core-js": "^3.37.1",
"cors": "^2.8.5",
"dayjs": "^1.11.9",
@ -26,6 +26,7 @@
"firebase-functions": "^6.1.1",
"htmlparser2": "^9.0.0",
"jose": "^5.1.0",
"koa": "^2.16.0",
"langdetect": "^0.2.1",
"linkedom": "^0.18.4",
"lru-cache": "^11.0.2",
@ -41,6 +42,7 @@
"set-cookie-parser": "^2.6.0",
"simple-zstd": "^1.4.2",
"stripe": "^11.11.0",
"svg2png-wasm": "^1.4.1",
"tiktoken": "^1.0.16",
"tld-extract": "^2.1.0",
"turndown": "^7.1.3",
@ -62,7 +64,6 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"firebase-functions-test": "^3.0.0",
"koa": "^2.16.0",
"pino-pretty": "^13.0.0",
"replicate": "^0.16.1",
"typescript": "^5.5.4"
@ -4002,9 +4003,9 @@
}
},
"node_modules/civkit": {
"version": "0.9.0-f7b0ca7",
"resolved": "https://registry.npmjs.org/civkit/-/civkit-0.9.0-f7b0ca7.tgz",
"integrity": "sha512-WjF0zRY83Ewvx4fGs1O0PQD2Oyc/RlKCVGiO/LHdwEFwfldTqDE3XWdWv+brZ2GvsIsVVKVa+bEGP0SwJfrRXA==",
"version": "0.9.0-848ef4e",
"resolved": "https://registry.npmjs.org/civkit/-/civkit-0.9.0-848ef4e.tgz",
"integrity": "sha512-yxk5AKaiZSN4ntlwybVHYgUer402CSw06KzN7wvfaYra9evZkZ7MiFHGULqMnY7657k3CH0WV4n6jGfRj1Vpvw==",
"license": "AGPL",
"dependencies": {
"lodash": "^4.17.21",
@ -4022,7 +4023,7 @@
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"jschardet": "^3.0.0",
"koa": "^2.14.2",
"koa": "^2.15.4",
"koa-bodyparser": "^4.4.0",
"koa-compose": "^4.1.0",
"libmagic-ffi": "^0.1.4",
@ -11931,6 +11932,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg2png-wasm": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/svg2png-wasm/-/svg2png-wasm-1.4.1.tgz",
"integrity": "sha512-ZFy1NtwZVAsslaTQoI+/QqX2sg0vjmgJ/jGAuLZZvYcRlndI54hLPiwLC9JzXlFBerfxN5JiS7kpEUG0mrXS3Q==",
"license": "MIT"
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",

View File

@ -26,7 +26,7 @@
"axios": "^1.3.3",
"bcrypt": "^5.1.0",
"busboy": "^1.6.0",
"civkit": "^0.9.0-f7b0ca7",
"civkit": "^0.9.0-848ef4e",
"core-js": "^3.37.1",
"cors": "^2.8.5",
"dayjs": "^1.11.9",
@ -35,6 +35,7 @@
"firebase-functions": "^6.1.1",
"htmlparser2": "^9.0.0",
"jose": "^5.1.0",
"koa": "^2.16.0",
"langdetect": "^0.2.1",
"linkedom": "^0.18.4",
"lru-cache": "^11.0.2",
@ -50,6 +51,7 @@
"set-cookie-parser": "^2.6.0",
"simple-zstd": "^1.4.2",
"stripe": "^11.11.0",
"svg2png-wasm": "^1.4.1",
"tiktoken": "^1.0.16",
"tld-extract": "^2.1.0",
"turndown": "^7.1.3",
@ -71,7 +73,6 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"firebase-functions-test": "^3.0.0",
"koa": "^2.16.0",
"pino-pretty": "^13.0.0",
"replicate": "^0.16.1",
"typescript": "^5.5.4"

View File

@ -1,7 +1,7 @@
import { AssertionFailureError, AsyncService, HashManager } from 'civkit';
import { singleton } from 'tsyringe';
import { GlobalLogger } from './logger';
import { CanvasService } from '../shared/services/canvas';
import { CanvasService } from './canvas';
import { ImageInterrogationManager } from '../shared/services/common-iminterrogate';
import { ImgBrief } from './puppeteer';
import { ImgAlt } from '../db/img-alt';
@ -32,13 +32,20 @@ export class AltTextService extends AsyncService {
async caption(url: string) {
try {
const img = await this.canvasService.loadImage(url);
const contentTypeHint = Reflect.get(img, 'contentType');
if (Math.min(img.naturalHeight, img.naturalWidth) < 64) {
throw new AssertionFailureError({ message: `Image is too small to generate alt text for url ${url}` });
}
const resized = this.canvasService.fitImageToSquareBox(img, 1024);
const exported = await this.canvasService.canvasToBuffer(resized, 'image/png');
const r = await this.imageInterrogator.interrogate('vertex-gemini-1.5-flash-002', {
const svgHint = contentTypeHint.includes('svg') ? `Beware this image is a SVG rendered on a gray background, the gray background is not part of the image.\n\n` : '';
const svgSystemHint = contentTypeHint.includes('svg') ? ` Sometimes the system renders SVG on a gray background. When this happens, you must not include the gray background in the description.` : '';
const r = await this.imageInterrogator.interrogate('vertex-gemini-2.0-flash', {
image: exported,
prompt: `Yield a concise image caption sentence in third person.`,
system: 'You are BLIP2, an image caption model.',
prompt: `${svgHint}Give a concise image caption descriptive sentence in third person. Start directly with the description.`,
system: `You are BLIP2, an image caption model. You will generate Alt Text (in web pages) for any image for a11y purposes. You must not start with "This image is sth...", instead, start direly with "sth..."${svgSystemHint}`,
});
return r.replaceAll(/[\n\"]|(\.\s*$)/g, '').trim();
@ -73,7 +80,7 @@ export class AltTextService extends AsyncService {
if (this.asyncLocalContext.ctx.DNT) {
// Don't cache alt text if DNT is set
return;
return generatedCaption;
}
// Don't try again until the next day

191
src/services/canvas.ts Normal file
View File

@ -0,0 +1,191 @@
import { singleton, container } from 'tsyringe';
import { AsyncService, mimeOf, ParamValidationError, SubmittedDataMalformedError, /* downloadFile */ } from 'civkit';
import { readFile } from 'fs/promises';
import type canvas from '@napi-rs/canvas';
export type { Canvas, Image } from '@napi-rs/canvas';
import { GlobalLogger } from './logger';
import { TempFileManager } from './temp-file';
import { isMainThread } from 'worker_threads';
import type { svg2png } from 'svg2png-wasm' with { 'resolution-mode': 'import' };
import path from 'path';
import { Threaded } from './threaded';
const downloadFile = async (uri: string) => {
const resp = await fetch(uri);
if (!(resp.ok && resp.body)) {
throw new Error(`Unexpected response ${resp.statusText}`);
}
const contentLength = parseInt(resp.headers.get('content-length') || '0');
if (contentLength > 1024 * 1024 * 100) {
throw new Error('File too large');
}
const buff = await resp.arrayBuffer();
return { buff, contentType: resp.headers.get('content-type') };
};
@singleton()
export class CanvasService extends AsyncService {
logger = this.globalLogger.child({ service: this.constructor.name });
svg2png!: typeof svg2png;
canvas!: typeof canvas;
constructor(
protected temp: TempFileManager,
protected globalLogger: GlobalLogger,
) {
super(...arguments);
}
override async init() {
await this.dependencyReady();
if (!isMainThread) {
const { createSvg2png, initialize } = require('svg2png-wasm');
const wasmBuff = await readFile(path.resolve(path.dirname(require.resolve('svg2png-wasm')), '../svg2png_wasm_bg.wasm'));
const fontBuff = await readFile(path.resolve(__dirname, '../../licensed/SourceHanSansSC-Regular.otf'));
await initialize(wasmBuff);
this.svg2png = createSvg2png({
fonts: [Uint8Array.from(fontBuff)],
defaultFontFamily: {
serifFamily: 'Source Han Sans SC',
sansSerifFamily: 'Source Han Sans SC',
cursiveFamily: 'Source Han Sans SC',
fantasyFamily: 'Source Han Sans SC',
monospaceFamily: 'Source Han Sans SC',
}
});
}
this.canvas = require('@napi-rs/canvas');
this.emit('ready');
}
@Threaded()
async renderSvgToPng(svgContent: string,) {
return this.svg2png(svgContent, { backgroundColor: '#D3D3D3' });
}
protected async _loadImage(input: string | Buffer) {
let buff;
let contentType;
do {
if (typeof input === 'string') {
if (input.startsWith('data:')) {
const firstComma = input.indexOf(',');
const header = input.slice(0, firstComma);
const data = input.slice(firstComma + 1);
const encoding = header.split(';')[1];
contentType = header.split(';')[0].split(':')[1];
if (encoding?.startsWith('base64')) {
buff = Buffer.from(data, 'base64');
} else {
buff = Buffer.from(decodeURIComponent(data), 'utf-8');
}
break;
}
if (input.startsWith('http')) {
const r = await downloadFile(input);
buff = Buffer.from(r.buff);
contentType = r.contentType;
break;
}
}
if (Buffer.isBuffer(input)) {
buff = input;
const mime = await mimeOf(buff);
contentType = `${mime.mediaType}/${mime.subType}`;
break;
}
throw new ParamValidationError('Invalid input');
} while (false);
if (!buff) {
throw new ParamValidationError('Invalid input');
}
if (contentType?.includes('svg')) {
buff = await this.renderSvgToPng(buff.toString('utf-8'));
}
const img = await this.canvas.loadImage(buff);
Reflect.set(img, 'contentType', contentType);
return img;
}
async loadImage(uri: string | Buffer) {
const t0 = Date.now();
try {
const theImage = await this._loadImage(uri);
const t1 = Date.now();
this.logger.debug(`Image loaded in ${t1 - t0}ms`);
return theImage;
} catch (err: any) {
if (err?.message?.includes('Unsupported image type') || err?.message?.includes('unsupported')) {
this.logger.warn(`Failed to load image ${uri.slice(0, 128)}`, { err });
throw new SubmittedDataMalformedError(`Unknown image format for ${uri.slice(0, 128)}`);
}
throw err;
}
}
fitImageToSquareBox(image: canvas.Image | canvas.Canvas, size: number = 1024) {
// this.logger.debug(`Fitting image(${ image.width }x${ image.height }) to ${ size } box`);
// const t0 = Date.now();
if (image.width <= size && image.height <= size) {
if (image instanceof this.canvas.Canvas) {
return image;
}
const canvasInstance = this.canvas.createCanvas(image.width, image.height);
const ctx = canvasInstance.getContext('2d');
ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvasInstance.width, canvasInstance.height);
// this.logger.debug(`No need to resize, copied to canvas in ${ Date.now() - t0 } ms`);
return canvasInstance;
}
const aspectRatio = image.width / image.height;
const resizedWidth = Math.round(aspectRatio > 1 ? size : size * aspectRatio);
const resizedHeight = Math.round(aspectRatio > 1 ? size / aspectRatio : size);
const canvasInstance = this.canvas.createCanvas(resizedWidth, resizedHeight);
const ctx = canvasInstance.getContext('2d');
ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, resizedWidth, resizedHeight);
// this.logger.debug(`Resized to ${ resizedWidth }x${ resizedHeight } in ${ Date.now() - t0 } ms`);
return canvasInstance;
}
corpImage(image: canvas.Image | canvas.Canvas, x: number, y: number, w: number, h: number) {
// this.logger.debug(`Cropping image(${ image.width }x${ image.height }) to ${ w }x${ h } at ${ x },${ y } `);
// const t0 = Date.now();
const canvasInstance = this.canvas.createCanvas(w, h);
const ctx = canvasInstance.getContext('2d');
ctx.drawImage(image, x, y, w, h, 0, 0, w, h);
// this.logger.debug(`Crop complete in ${ Date.now() - t0 } ms`);
return canvasInstance;
}
canvasToDataUrl(canvas: canvas.Canvas, mimeType?: 'image/png' | 'image/jpeg') {
// this.logger.debug(`Exporting canvas(${ canvas.width }x${ canvas.height })`);
// const t0 = Date.now();
return canvas.toDataURLAsync((mimeType || 'image/png') as 'image/png');
}
async canvasToBuffer(canvas: canvas.Canvas, mimeType?: 'image/png' | 'image/jpeg') {
// this.logger.debug(`Exporting canvas(${ canvas.width }x${ canvas.height })`);
// const t0 = Date.now();
return canvas.toBuffer((mimeType || 'image/png') as 'image/png');
}
}
const instance = container.resolve(CanvasService);
export default instance;

View File

@ -169,10 +169,12 @@ export class JSDomControl extends AsyncService {
Array.from(rootDoc.querySelectorAll('img[src],img[data-src]'))
.map((x: any) => [x.getAttribute('src'), x.getAttribute('data-src'), x.getAttribute('alt')])
.forEach(([u1, u2, alt]) => {
let absUrl: string | undefined;
if (u1) {
try {
const u1Txt = new URL(u1, snapshot.rebase || snapshot.href).toString();
imgSet.add(u1Txt);
absUrl = u1Txt;
} catch (err) {
// void 0;
}
@ -181,14 +183,17 @@ export class JSDomControl extends AsyncService {
try {
const u2Txt = new URL(u2, snapshot.rebase || snapshot.href).toString();
imgSet.add(u2Txt);
absUrl = u2Txt;
} catch (err) {
// void 0;
}
}
rebuiltImgs.push({
src: u1 || u2,
alt
});
if (absUrl) {
rebuiltImgs.push({
src: absUrl,
alt
});
}
});
const r = {

View File

@ -395,7 +395,7 @@ function giveSnapshot(stopActiveSnapshot, overrideDomAnalysis) {
description: document.head?.querySelector('meta[name="description"]')?.getAttribute('content') ?? '',
href: document.location.href,
html: document.documentElement?.outerHTML,
htmlSignificantlyModifiedByJs: Boolean(Math.abs(thisElemCount - initialElemCount) / (initialElemCount + Number.EPSILON) > 0.1),
htmlSignificantlyModifiedByJs: Boolean(Math.abs(thisElemCount - initialElemCount) / (initialElemCount + Number.EPSILON) > 0.05),
text: document.body?.innerText,
shadowExpanded: shadowDomPresent() ? cloneAndExpandShadowRoots()?.outerHTML : undefined,
parsed: parsed,
@ -407,16 +407,18 @@ function giveSnapshot(stopActiveSnapshot, overrideDomAnalysis) {
if (document.baseURI !== r.href) {
r.rebase = document.baseURI;
}
if (parsed && parsed.content) {
const elem = document.createElement('div');
elem.innerHTML = parsed.content;
r.imgs = briefImgs(elem);
} else {
const allImgs = briefImgs();
if (allImgs.length === 1) {
r.imgs = allImgs;
r.imgs = briefImgs().filter((x)=> {
if (x.complete) {
if (Math.min(x.width, x.height, x.naturalWidth, x.naturalHeight) < 64) {
return false;
}
}
}
const m = Math.min(x.width, x.height);
if (m && m < 64) {
return false;
}
return true;
});
return r;
}
@ -756,7 +758,7 @@ export class PuppeteerControl extends AsyncService {
dElem = delta /(previousElemCount + Number.EPSILON);
}
if (dt < 1500 && dElem < 0.1) {
if (dt < 1200 && dElem < 0.05) {
return;
}

View File

@ -213,6 +213,7 @@ export class SnapshotFormatter extends AsyncService {
const imageSummary = {} as { [k: string]: string; };
const imageIdxTrack = new Map<string, number[]>();
const uid = this.threadLocal.get('uid');
do {
if (pdfMode) {
contentText = (snapshot.parsed?.content || snapshot.text || '').trim();
@ -229,10 +230,10 @@ export class SnapshotFormatter extends AsyncService {
break;
}
const urlToAltMap: { [k: string]: string | undefined; } = {};
const noGFMOpts = this.threadLocal.get('noGfm');
const imageRetention = this.threadLocal.get('retainImages') as CrawlerOptions['retainImages'];
let imgIdx = 0;
const urlToAltMap: { [k: string]: string | undefined; } = {};
const customRules: { [k: string]: Rule; } = {
'img-retention': {
filter: 'img',
@ -267,41 +268,37 @@ export class SnapshotFormatter extends AsyncService {
if (!src) {
return '';
}
const mapped = urlToAltMap[originalSrc];
const keySrc = (originalSrc.startsWith('data:') ? this.dataUrlToBlobUrl(originalSrc, snapshot.rebase) : src).trim();
const mapped = urlToAltMap[keySrc];
const imgSerial = ++imgIdx;
const idxArr = imageIdxTrack.has(src) ? imageIdxTrack.get(src)! : [];
const idxArr = imageIdxTrack.has(keySrc) ? imageIdxTrack.get(keySrc)! : [];
idxArr.push(imgSerial);
imageIdxTrack.set(src, idxArr);
imageIdxTrack.set(keySrc, idxArr);
if (mapped) {
imageSummary[src] = mapped || alt;
imageSummary[keySrc] = mapped || alt;
if (imageRetention === 'alt_p') {
return `(Image ${imgIdx}: ${mapped || alt})`;
return `(Image ${imgSerial}: ${mapped || alt})`;
}
if (src?.startsWith('data:') && imgDataUrlToObjectUrl) {
const mappedUrl = new URL(`blob:${nominalUrl?.origin || ''}/${md5Hasher.hash(src)}`);
mappedUrl.protocol = 'blob:';
return `![Image ${imgIdx}: ${mapped || alt}](${mappedUrl})`;
if (imgDataUrlToObjectUrl) {
return `![Image ${imgSerial}: ${mapped || alt}](${keySrc})`;
}
return `![Image ${imgIdx}: ${mapped || alt}](${src})`;
return `![Image ${imgSerial}: ${mapped || alt}](${src})`;
} else if (imageRetention === 'alt_p') {
return alt ? `(Image ${imgIdx}: ${alt})` : '';
return alt ? `(Image ${imgSerial}: ${alt})` : '';
}
imageSummary[src] = alt || '';
imageSummary[keySrc] = alt || '';
if (src?.startsWith('data:') && imgDataUrlToObjectUrl) {
const mappedUrl = new URL(`blob:${nominalUrl?.origin || ''}/${md5Hasher.hash(src)}`);
mappedUrl.protocol = 'blob:';
return alt ? `![Image ${imgIdx}: ${alt}](${mappedUrl})` : `![Image ${imgIdx}](${mappedUrl})`;
if (imgDataUrlToObjectUrl) {
return alt ? `![Image ${imgSerial}: ${alt}](${keySrc})` : `![Image ${imgSerial}](${keySrc})`;
}
return alt ? `![Image ${imgIdx}: ${alt}](${src})` : `![Image ${imgIdx}](${src})`;
return alt ? `![Image ${imgSerial}: ${alt}](${src})` : `![Image ${imgSerial}](${src})`;
}
} as Rule
};
@ -343,7 +340,9 @@ export class SnapshotFormatter extends AsyncService {
return undefined;
});
if (r && x.src) {
urlToAltMap[x.src.trim()] = r;
// note x.src here is already rebased to absolute url by browser/upstream.
const keySrc = (x.src.startsWith('data:') ? this.dataUrlToBlobUrl(x.src, snapshot.rebase) : x.src).trim();
urlToAltMap[keySrc] = r;
}
});
@ -416,13 +415,10 @@ export class SnapshotFormatter extends AsyncService {
.toPairs()
.map(
([url, alt], i) => {
if (imgDataUrlToObjectUrl && url.startsWith('data:')) {
const refUrl = new URL(formatted.url!);
const mappedUrl = new URL(`blob:${refUrl.origin}/${md5Hasher.hash(url)}`);
const idxTrack = imageIdxTrack.get(url);
const tag = idxTrack?.length ? `Image ${_.uniq(idxTrack).join(',')}` : `Hidden Image ${i + 1}`;
url = mappedUrl.toString();
}
return [`Image ${(imageIdxTrack?.get(url) || [i + 1]).join(',')}${alt ? `: ${alt}` : ''}`, url];
return [`${tag}${alt ? `: ${alt}` : ''}`, url];
}
).fromPairs()
.value();
@ -522,6 +518,13 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`;
return f as FormattedPage;
}
dataUrlToBlobUrl(dataUrl: string, baseUrl: string = 'http://localhost/') {
const refUrl = new URL(baseUrl);
const mappedUrl = new URL(`blob:${refUrl.origin || 'localhost'}/${md5Hasher.hash(dataUrl)}`);
return mappedUrl.href;
}
async getGeneralSnapshotMixins(snapshot: PageSnapshot) {
let inferred;
const mixin: any = {};
@ -534,10 +537,11 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`;
for (const img of inferred.imgs) {
const imgSerial = ++imgIdx;
const idxArr = imageIdxTrack.has(img.src) ? imageIdxTrack.get(img.src)! : [];
const keySrc = (img.src.startsWith('data:') ? this.dataUrlToBlobUrl(img.src, snapshot.rebase) : img.src).trim();
const idxArr = imageIdxTrack.has(keySrc) ? imageIdxTrack.get(keySrc)! : [];
idxArr.push(imgSerial);
imageIdxTrack.set(img.src, idxArr);
imageSummary[img.src] = img.alt || '';
imageIdxTrack.set(keySrc, idxArr);
imageSummary[keySrc] = img.alt || '';
}
mixin.images =
@ -545,7 +549,10 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`;
.toPairs()
.map(
([url, alt], i) => {
return [`Image ${(imageIdxTrack?.get(url) || [i + 1]).join(',')}${alt ? `: ${alt}` : ''}`, url];
const idxTrack = imageIdxTrack.get(url);
const tag = idxTrack?.length ? `Image ${_.uniq(idxTrack).join(',')}` : `Hidden Image ${i + 1}`;
return [`${tag}${alt ? `: ${alt}` : ''}`, url];
}
).fromPairs()
.value();
@ -611,14 +618,9 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`;
const src = (node.getAttribute('src') || '').trim();
const alt = cleanAttribute(node.getAttribute('alt')) || '';
if (options.url) {
const refUrl = new URL(options.url);
const mappedUrl = new URL(`blob:${refUrl.origin}/${md5Hasher.hash(src)}`);
const blobUrl = this.dataUrlToBlobUrl(src, options.url?.toString());
return `![${alt}](${mappedUrl})`;
}
return `![${alt}](blob:${md5Hasher.hash(src)})`;
return `![${alt}](${blobUrl})`;
}
});
}
@ -817,6 +819,7 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`;
if (contentType.startsWith('image/')) {
snapshot.html = `<html style="height: 100%;"><head><meta name="viewport" content="width=device-width, minimum-scale=0.1"><title>${fileName}</title></head><body style="margin: 0px; height: 100%; background-color: rgb(14, 14, 14);"><img style="display: block;-webkit-user-select: none;margin: auto;background-color: hsl(0, 0%, 90%);transition: background-color 300ms;" src="${url.href}"></body></html>`;
snapshot.title = fileName;
snapshot.imgs = [{ src: url.href }];
return snapshot;
}

@ -1 +1 @@
Subproject commit 4b1061e6e9623bb98b82ac6f86004988c7211385
Subproject commit a2ebcb882fa92644cc3dfd6b8d8e66f06dd940e9