mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-11 20:14:42 +02:00
Compare commits
45 Commits
improve-im
...
frontend-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25736436c2 | ||
|
|
262cd79e63 | ||
|
|
a7118a7cb6 | ||
|
|
ccd16fa5b3 | ||
|
|
a5165510af | ||
|
|
cfadbc7abf | ||
|
|
0596d4d09e | ||
|
|
2de9891478 | ||
|
|
c6b7d44812 | ||
|
|
8df9cd5b9a | ||
|
|
e42f7436ab | ||
|
|
efa09e3ee1 | ||
|
|
71e73a7faf | ||
|
|
0dfbd0b085 | ||
|
|
560c0598c9 | ||
|
|
78fd36ff0e | ||
|
|
31487c013b | ||
|
|
ce7657081b | ||
|
|
80b5ec66a4 | ||
|
|
5d0dd40434 | ||
|
|
8f2759eb47 | ||
|
|
9858fd1417 | ||
|
|
b487f91087 | ||
|
|
cd953e918c | ||
|
|
0c7ee11a2b | ||
|
|
551162b70a | ||
|
|
8dc5962ce9 | ||
|
|
8bc8ebc333 | ||
|
|
7dcf7658b2 | ||
|
|
4a41b1461e | ||
|
|
5f2022341a | ||
|
|
5f10968491 | ||
|
|
5856784288 | ||
|
|
c5951175ef | ||
|
|
48f676511c | ||
|
|
ce10eceda1 | ||
|
|
982d4034bd | ||
|
|
67f25a7da7 | ||
|
|
364ccd07ff | ||
|
|
0deac44320 | ||
|
|
812b5fbf0b | ||
|
|
7247535d65 | ||
|
|
790c84dcca | ||
|
|
21473857d9 | ||
|
|
96a454ee3a |
523
.github/scripts/chrome.mts
vendored
Normal file
523
.github/scripts/chrome.mts
vendored
Normal file
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
|
||||
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
documentUrl?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
protocol?: string;
|
||||
remoteIPAddress?: string;
|
||||
remotePort?: number;
|
||||
encodedDataLength: number;
|
||||
decodedBodyLength: number;
|
||||
fromDiskCache: boolean;
|
||||
fromServiceWorker: boolean;
|
||||
finished: boolean;
|
||||
failed: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type WebSocketConnection = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
createdAt: number;
|
||||
handshakeRequestHeaders?: Record<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
closedAt?: number;
|
||||
sentFrameCount: number;
|
||||
receivedFrameCount: number;
|
||||
sentBytes: number;
|
||||
receivedBytes: number;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
export type NetworkSummary = {
|
||||
requestCount: number;
|
||||
webSocketConnectionCount: number;
|
||||
webSocketSentBytes: number;
|
||||
webSocketReceivedBytes: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TabMemory = {
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
tabMemory: TabMemory;
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
type PlaywrightModule = typeof import('playwright');
|
||||
|
||||
const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url));
|
||||
|
||||
function loadPlaywright(): PlaywrightModule {
|
||||
return requireFromFrontend('playwright') as PlaywrightModule;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized[key] = String(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) {
|
||||
if (frame?.payloadData == null) return 0;
|
||||
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
|
||||
return Buffer.byteLength(frame.payloadData, 'base64');
|
||||
}
|
||||
|
||||
type PlaywrightBrowserOptions = {
|
||||
scenarioTimeoutMs: number;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export class HeadlessChromeController {
|
||||
public networkRequests: NetworkRequest[] = [];
|
||||
public webSocketConnections: WebSocketConnection[] = [];
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
private readonly cdp: CDPSession;
|
||||
private pendingNetworkDetailReads: Promise<void>[] = [];
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
options: PlaywrightBrowserOptions,
|
||||
) {
|
||||
this.browser = browser;
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
this.cdp = cdp;
|
||||
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
|
||||
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
|
||||
}
|
||||
|
||||
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
|
||||
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
|
||||
const { chromium } = loadPlaywright();
|
||||
const browser = await chromium.launch({
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-sandbox',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
baseURL: options.baseUrl,
|
||||
locale: 'en-US',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// @ts-expect-error Test-only runtime hint consumed by Misskey frontend code.
|
||||
window.isPlaywright = true;
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
return new HeadlessChromeController(browser, context, page, cdp, options);
|
||||
} catch (error) {
|
||||
await browser.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async with<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
const browser = await HeadlessChromeController.create(label, options);
|
||||
try {
|
||||
return await callback(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
|
||||
const readRequestBody = (row: NetworkRequest) => {
|
||||
if (!row.hasRequestBody || row.requestBody != null) return;
|
||||
const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', {
|
||||
requestId: row.requestId,
|
||||
}).then(result => {
|
||||
row.requestBody = result.postData;
|
||||
}).catch(() => {
|
||||
// Some requests expose hasPostData but no longer have retrievable body data.
|
||||
});
|
||||
this.pendingNetworkDetailReads.push(pending);
|
||||
};
|
||||
|
||||
this.cdp.on('Network.requestWillBeSent', params => {
|
||||
if (params.request?.url == null) return;
|
||||
const row: NetworkRequest = {
|
||||
requestId: params.requestId,
|
||||
url: params.request.url,
|
||||
method: params.request.method ?? 'GET',
|
||||
resourceType: params.type ?? 'Other',
|
||||
startedAt: params.timestamp ?? 0,
|
||||
documentUrl: params.documentURL,
|
||||
requestHeaders: normalizeHeaders(params.request.headers),
|
||||
requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined,
|
||||
hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string',
|
||||
encodedDataLength: 0,
|
||||
decodedBodyLength: 0,
|
||||
fromDiskCache: false,
|
||||
fromServiceWorker: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
};
|
||||
requests.set(params.requestId, row);
|
||||
this.networkRequests.push(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketCreated', params => {
|
||||
if (params.requestId == null || params.url == null) return;
|
||||
const row: WebSocketConnection = {
|
||||
requestId: params.requestId,
|
||||
url: params.url,
|
||||
createdAt: params.timestamp ?? 0,
|
||||
sentFrameCount: 0,
|
||||
receivedFrameCount: 0,
|
||||
sentBytes: 0,
|
||||
receivedBytes: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
webSockets.set(params.requestId, row);
|
||||
this.webSocketConnections.push(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketHandshakeResponseReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeResponseStatus = params.response?.status;
|
||||
row.handshakeResponseStatusText = params.response?.statusText;
|
||||
row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameSent', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.sentFrameCount += 1;
|
||||
row.sentBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.receivedFrameCount += 1;
|
||||
row.receivedBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameError', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.errorCount += 1;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketClosed', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.closedAt = params.timestamp ?? 0;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.responseReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.status = params.response?.status;
|
||||
row.statusText = params.response?.statusText;
|
||||
row.mimeType = params.response?.mimeType;
|
||||
row.responseHeaders = normalizeHeaders(params.response?.headers);
|
||||
row.protocol = params.response?.protocol;
|
||||
row.remoteIPAddress = params.response?.remoteIPAddress;
|
||||
row.remotePort = params.response?.remotePort;
|
||||
row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders);
|
||||
row.fromDiskCache = params.response?.fromDiskCache === true;
|
||||
row.fromServiceWorker = params.response?.fromServiceWorker === true;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.dataReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.decodedBodyLength += params.dataLength ?? 0;
|
||||
row.encodedDataLength += params.encodedDataLength ?? 0;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFinished', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.finished = true;
|
||||
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFailed', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.failed = true;
|
||||
row.finished = true;
|
||||
row.errorText = params.errorText;
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
await this.cdp.send('Network.enable');
|
||||
await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await this.cdp.send('Network.setBypassServiceWorker', { bypass: true });
|
||||
await this.cdp.send('Page.enable');
|
||||
await this.cdp.send('Runtime.enable');
|
||||
await this.cdp.send('Performance.enable');
|
||||
}
|
||||
|
||||
public async waitForNetworkDetails() {
|
||||
let settledCount = 0;
|
||||
while (settledCount < this.pendingNetworkDetailReads.length) {
|
||||
const pending = this.pendingNetworkDetailReads.slice(settledCount);
|
||||
settledCount = this.pendingNetworkDetailReads.length;
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
|
||||
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
|
||||
]) as T;
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const tabMemory = await this.collectTabMemory();
|
||||
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
|
||||
const longTasks = performance.getEntriesByType('longtask');
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
return {
|
||||
firstPaintMs: paintEntries['first-paint'],
|
||||
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
|
||||
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
|
||||
loadEventEndMs: navigation?.loadEventEnd,
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
|
||||
resourceEntryCount: resourceEntries.length,
|
||||
domElements: document.getElementsByTagName('*').length,
|
||||
};
|
||||
})()`);
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap,
|
||||
tabMemory,
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
public async collectTabMemory(): Promise<TabMemory> {
|
||||
const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => {
|
||||
const measureMemory = performance.measureUserAgentSpecificMemory;
|
||||
if (typeof measureMemory !== 'function') return {};
|
||||
const result = await measureMemory.call(performance);
|
||||
return { bytes: result.bytes };
|
||||
})()`, 60_000);
|
||||
|
||||
const userAgentSpecificBytes = userAgentSpecificMemory?.bytes;
|
||||
if (!Number.isFinite(userAgentSpecificBytes)) {
|
||||
throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes');
|
||||
}
|
||||
|
||||
return {
|
||||
totalBytes: userAgentSpecificBytes as number,
|
||||
};
|
||||
}
|
||||
|
||||
public async takeHeapSnapshot(savePath?: string) {
|
||||
const chunks: string[] = [];
|
||||
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
|
||||
chunks.push(params.chunk);
|
||||
});
|
||||
|
||||
await this.cdp.send('HeapProfiler.enable');
|
||||
await this.cdp.send('HeapProfiler.collectGarbage');
|
||||
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
|
||||
const content = chunks.join('');
|
||||
if (savePath != null) {
|
||||
await writeFile(savePath, content);
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.cdp.detach().catch(() => undefined);
|
||||
await this.context.close().catch(() => undefined);
|
||||
await this.browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function isMeasurableRequest(row: NetworkRequest) {
|
||||
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
|
||||
}
|
||||
|
||||
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const rows = requestRows.filter(isMeasurableRequest);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = byResourceType[row.resourceType] ?? {
|
||||
requests: 0,
|
||||
encodedBytes: 0,
|
||||
decodedBodyBytes: 0,
|
||||
};
|
||||
summary.requests += 1;
|
||||
summary.encodedBytes += row.encodedDataLength;
|
||||
summary.decodedBodyBytes += row.decodedBodyLength;
|
||||
byResourceType[row.resourceType] = summary;
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: rows.length,
|
||||
webSocketConnectionCount: webSocketRows == null
|
||||
? rows.filter(row => row.resourceType === 'WebSocket').length
|
||||
: webSocketRows.length,
|
||||
webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0,
|
||||
webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0,
|
||||
finishedRequestCount: rows.filter(row => row.finished).length,
|
||||
failedRequestCount: rows.filter(row => row.failed).length,
|
||||
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
|
||||
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
|
||||
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
|
||||
sameOriginEncodedBytes: rows
|
||||
.filter(row => isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
thirdPartyEncodedBytes: rows
|
||||
.filter(row => !isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
byResourceType,
|
||||
largestRequests: rows
|
||||
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
|
||||
.slice(0, 15)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
status: row.status,
|
||||
encodedBytes: row.encodedDataLength,
|
||||
decodedBodyBytes: row.decodedBodyLength,
|
||||
})),
|
||||
failedRequests: rows
|
||||
.filter(row => row.failed)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
errorText: row.errorText,
|
||||
status: row.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
448
.github/scripts/frontend-browser-detailed-html.mts
vendored
Normal file
448
.github/scripts/frontend-browser-detailed-html.mts
vendored
Normal file
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as util from './utility.mts';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts';
|
||||
import type { NetworkRequest } from './chrome.mts';
|
||||
|
||||
type DiffDirection = 'added' | 'removed';
|
||||
|
||||
type RequestDiff = {
|
||||
direction: DiffDirection;
|
||||
round: number;
|
||||
baseCount: number;
|
||||
headCount: number;
|
||||
request: NetworkRequest;
|
||||
};
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeAttribute(value: unknown) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function isHttpRequest(request: NetworkRequest) {
|
||||
try {
|
||||
const { protocol } = new URL(request.url);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestKey(request: NetworkRequest) {
|
||||
return [
|
||||
request.method,
|
||||
request.resourceType,
|
||||
request.url,
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
function groupRequests(requests: NetworkRequest[] | undefined) {
|
||||
const grouped = new Map<string, NetworkRequest[]>();
|
||||
for (const request of requests ?? []) {
|
||||
if (!isHttpRequest(request)) continue;
|
||||
const key = requestKey(request);
|
||||
const rows = grouped.get(key) ?? [];
|
||||
rows.push(request);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function byRound(samples: BrowserMeasurementSample[]) {
|
||||
return new Map(samples.map(sample => [sample.round, sample]));
|
||||
}
|
||||
|
||||
function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) {
|
||||
const baseRequests = groupRequests(baseSample?.networkRequests);
|
||||
const headRequests = groupRequests(headSample?.networkRequests);
|
||||
const keys = [...new Set([
|
||||
...baseRequests.keys(),
|
||||
...headRequests.keys(),
|
||||
])].toSorted();
|
||||
const diffs: RequestDiff[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const baseRows = baseRequests.get(key) ?? [];
|
||||
const headRows = headRequests.get(key) ?? [];
|
||||
if (headRows.length > baseRows.length) {
|
||||
for (const request of headRows.slice(baseRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'added',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
} else if (baseRows.length > headRows.length) {
|
||||
for (const request of baseRows.slice(headRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'removed',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const baseSamples = byRound(base.samples);
|
||||
const headSamples = byRound(head.samples);
|
||||
const rounds = [...new Set([
|
||||
...baseSamples.keys(),
|
||||
...headSamples.keys(),
|
||||
])].toSorted((a, b) => a - b);
|
||||
return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round)));
|
||||
}
|
||||
|
||||
function formatMaybeJson(value: string | undefined) {
|
||||
if (value == null || value === '') return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, '\t');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeaders(headers: Record<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
for (const diff of diffs) {
|
||||
counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1);
|
||||
}
|
||||
return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(type)}</td>
|
||||
<td class="num">${util.formatNumber(count)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${util.formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${util.formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${util.formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${util.formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeRows === '' ? '' : `
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false) {
|
||||
if (content == null || content === '') return '';
|
||||
return `
|
||||
<details${open ? ' open' : ''}>
|
||||
<summary>${escapeHtml(title)}</summary>
|
||||
<pre>${escapeHtml(content)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderRequest(diff: RequestDiff) {
|
||||
const { request } = diff;
|
||||
const requestBody = formatMaybeJson(request.requestBody);
|
||||
const requestHeaders = formatHeaders(request.requestHeaders);
|
||||
const responseHeaders = formatHeaders(request.responseHeaders);
|
||||
const bodyNote = requestBody == null && request.hasRequestBody === true
|
||||
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${escapeHtml(request.method)}</span>
|
||||
<span class="type">${escapeHtml(request.resourceType)}</span>
|
||||
<span class="status">${escapeHtml(request.status ?? '-')}</span>
|
||||
</header>
|
||||
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderRound(round: number, diffs: RequestDiff[]) {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
return `
|
||||
<section>
|
||||
<h2>Round ${util.formatNumber(round)}</h2>
|
||||
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${diffs.map(renderRequest).join('\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const diffs = diffReports(base, head);
|
||||
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const content = diffs.length === 0
|
||||
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
|
||||
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f7f7f8;
|
||||
--fg: #202124;
|
||||
--muted: #5f6368;
|
||||
--card: #ffffff;
|
||||
--border: #dfe1e5;
|
||||
--added: #137333;
|
||||
--added-bg: #e6f4ea;
|
||||
--removed: #a50e0e;
|
||||
--removed-bg: #fce8e6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--fg: #e8eaed;
|
||||
--muted: #bdc1c6;
|
||||
--card: #1b1d20;
|
||||
--border: #3c4043;
|
||||
--added-bg: #17351f;
|
||||
--removed-bg: #3c1f1d;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin: 32px 0 8px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.summary > div, .request, table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.summary > div {
|
||||
padding: 14px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.added-text {
|
||||
color: var(--added);
|
||||
}
|
||||
.removed-text {
|
||||
color: var(--removed);
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
.requests {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.request {
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request.added {
|
||||
border-left: 4px solid var(--added);
|
||||
}
|
||||
.request.removed {
|
||||
border-left: 4px solid var(--removed);
|
||||
}
|
||||
.request header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.badge, .method, .type, .status {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.added .badge {
|
||||
background: var(--added-bg);
|
||||
color: var(--added);
|
||||
}
|
||||
.removed .badge {
|
||||
background: var(--removed-bg);
|
||||
color: var(--removed);
|
||||
}
|
||||
.method, .type, .status {
|
||||
background: color-mix(in srgb, var(--muted) 14%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.url {
|
||||
display: block;
|
||||
margin: 8px 0 12px;
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
dl div {
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--muted) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
if (baseFile == null || headFile == null || outputFile == null) {
|
||||
throw new Error('Usage: node frontend-browser-detailed-html.mts <base-browser.json> <head-browser.json> <output.html>');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
|
||||
await writeFile(outputFile, renderHtml(base, head));
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
378
.github/scripts/frontend-browser-report.mts
vendored
Normal file
378
.github/scripts/frontend-browser-report.mts
vendored
Normal file
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts';
|
||||
import type { NetworkRequest } from './chrome.mts';
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
durationMs: number;
|
||||
network: {
|
||||
requestCount: number;
|
||||
webSocketConnectionCount: number;
|
||||
webSocketSentBytes: number;
|
||||
webSocketReceivedBytes: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
tabMemory: {
|
||||
totalBytes: number;
|
||||
};
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
export type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
networkRequests?: NetworkRequest[];
|
||||
};
|
||||
|
||||
export type BrowserMetricsReport = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
sampleCount: number;
|
||||
aggregation: 'median';
|
||||
summary: BrowserMeasurement;
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
|
||||
function escapeCell(value: string) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength = 140) {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`;
|
||||
return `${util.formatNumber(value)} ms`;
|
||||
}
|
||||
|
||||
function formatSecondsAsMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
return formatMs(value * 1_000);
|
||||
}
|
||||
|
||||
function formatDelta(delta: number, formatter: (value: number) => string, colorThreshold = 0) {
|
||||
if (delta === 0) return formatter(0);
|
||||
return util.formatColoredDelta(delta, v => formatter(v), colorThreshold);
|
||||
}
|
||||
|
||||
function finiteValues(values: (number | null | undefined)[]) {
|
||||
return values.filter(value => Number.isFinite(value)) as number[];
|
||||
}
|
||||
|
||||
function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) {
|
||||
const values = finiteValues(report.samples.map(sample => getValue(sample)));
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
|
||||
const spread = sampleSpread(report, getSampleValue);
|
||||
if (spread == null) return formatter(value);
|
||||
return `${formatter(value)}<br>± ${formatter(spread)}`;
|
||||
}
|
||||
|
||||
function metricRow(
|
||||
label: string,
|
||||
base: BrowserMetricsReport,
|
||||
head: BrowserMetricsReport,
|
||||
getSummaryValue: (summary: BrowserMeasurement) => number,
|
||||
getSampleValue: (sample: BrowserMeasurementSample) => number,
|
||||
formatter: (value: number) => string,
|
||||
significantThreshold = 0,
|
||||
skipIfNotSignificant = true
|
||||
) {
|
||||
const baseValue = getSummaryValue(base.summary);
|
||||
const headValue = getSummaryValue(head.summary);
|
||||
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
|
||||
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
|
||||
// 有意な閾値に満たない場合はそもそもrowとして出力しない
|
||||
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
|
||||
|
||||
const percent = baseValue === 0 ? null : summary.median * 100 / baseValue;
|
||||
//const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}<br>${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
const deltaMedian = formatDelta(summary.median, formatter, significantThreshold);
|
||||
|
||||
//return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`;
|
||||
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`;
|
||||
}
|
||||
|
||||
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
|
||||
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
|
||||
}
|
||||
|
||||
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
|
||||
return resourceTypeBytes(sample, resourceTypes);
|
||||
}
|
||||
|
||||
function getMetric(report: BrowserMeasurement, key: string) {
|
||||
return report.performance.cdpMetrics[key];
|
||||
}
|
||||
|
||||
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
|
||||
const rows = [
|
||||
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
|
||||
metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all),
|
||||
//metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
|
||||
metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all),
|
||||
metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all),
|
||||
metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all),
|
||||
metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all),
|
||||
metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all),
|
||||
//metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
|
||||
//metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
|
||||
//metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber),
|
||||
//metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
|
||||
//metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
|
||||
//metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes),
|
||||
//metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes),
|
||||
//metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000),
|
||||
//metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber),
|
||||
//metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber),
|
||||
//metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber),
|
||||
//metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber),
|
||||
//metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber),
|
||||
//metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
|
||||
//metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
|
||||
metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all),
|
||||
metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all),
|
||||
metricRow('Tab memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all),
|
||||
].filter(row => row != null);
|
||||
|
||||
return [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
...rows,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
|
||||
const keys = [...new Set([
|
||||
...preferredOrder,
|
||||
...Object.keys(base.summary.network.byResourceType),
|
||||
...Object.keys(head.summary.network.byResourceType),
|
||||
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
|
||||
|
||||
const lines = [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2">Type</th>',
|
||||
'<th colspan="3">Requests</th>',
|
||||
'<th colspan="3">Encoded bytes</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||
const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||
lines.push('<tr>');
|
||||
lines.push(`<td><b>${key}</b></td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
|
||||
lines.push('</tr>');
|
||||
}
|
||||
|
||||
lines.push('</tbody>');
|
||||
lines.push('</table>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderLargestRequests(report: BrowserMetricsReport, title: string) {
|
||||
if (report.summary.network.largestRequests.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title}</summary>`,
|
||||
'',
|
||||
'| Resource | Type | Status | Encoded | Decoded |',
|
||||
'| --- | --- | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
for (const request of report.summary.network.largestRequests.slice(0, 10)) {
|
||||
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`);
|
||||
}
|
||||
|
||||
lines.push('', '</details>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFailedRequests(report: BrowserMetricsReport, title: string) {
|
||||
if (report.summary.network.failedRequests.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title}</summary>`,
|
||||
'',
|
||||
'| Resource | Type | Status | Error |',
|
||||
'| --- | --- | ---: | --- |',
|
||||
];
|
||||
|
||||
for (const request of report.summary.network.failedRequests.slice(0, 20)) {
|
||||
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`);
|
||||
}
|
||||
|
||||
lines.push('', '</details>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport {
|
||||
return {
|
||||
summary: report.summary.heapSnapshot,
|
||||
samples: report.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.heapSnapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
|
||||
headHeapSnapshotUrl?: string;
|
||||
detailedHtmlUrl?: string;
|
||||
} = {}) {
|
||||
const headHeapSnapshotUrl = options.headHeapSnapshotUrl;
|
||||
const detailedHtmlUrl = options.detailedHtmlUrl;
|
||||
const sampleSummary = base.sampleCount === head.sampleCount
|
||||
? `${base.sampleCount} samples per side`
|
||||
: `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`;
|
||||
const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
|
||||
const lines = [
|
||||
'## 🖥 Frontend Browser Metrics',
|
||||
'',
|
||||
'Only metrics showing significant changes are displayed.',
|
||||
'',
|
||||
renderSummaryTable(base, head),
|
||||
'',
|
||||
//`> Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Base/Head values are medians; Δ median is the median of paired Head - Base sample deltas; percent uses Δ median / Base median; ± and Δ MAD are median absolute deviations. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible.`,
|
||||
//'',
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
|
||||
'<details>',
|
||||
'<summary>Requests by resource type</summary>',
|
||||
'',
|
||||
renderResourceTypeTable(base, head),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>V8 heap snapshot statistics</summary>',
|
||||
'',
|
||||
heapSnapshotTable ?? '_No V8 heap snapshot data._',
|
||||
'',
|
||||
heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
|
||||
'',
|
||||
`[Download representative head heap snapshot](${headHeapSnapshotUrl})`,
|
||||
'</details>',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const section of [
|
||||
//renderLargestRequests(head, 'Largest representative head requests'),
|
||||
//renderFailedRequests(base, 'Failed representative base requests'),
|
||||
//renderFailedRequests(head, 'Failed representative head requests'),
|
||||
]) {
|
||||
if (section == null) continue;
|
||||
lines.push(section, '');
|
||||
}
|
||||
|
||||
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
if (baseFile == null || headFile == null || outputFile == null) {
|
||||
throw new Error('Usage: node frontend-browser-report.mts <base-browser.json> <head-browser.json> <output.md>');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
|
||||
await writeFile(outputFile, renderFrontendBrowserReport(base, head, {
|
||||
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL,
|
||||
detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL,
|
||||
}));
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
320
.github/scripts/heap-snapshot-util.mts
vendored
320
.github/scripts/heap-snapshot-util.mts
vendored
@@ -32,6 +32,326 @@ export type HeapSnapshotReport = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export const defaultHeapSnapshotBreakdownTopN = 6;
|
||||
|
||||
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
|
||||
const categories = {} as HeapSnapshotData['categories'];
|
||||
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
categories[category] = 0;
|
||||
nodeCounts[category] = 0;
|
||||
}
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: {} as HeapSnapshotData['breakdowns'],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) {
|
||||
if (category === 'strings') return type;
|
||||
|
||||
if (category === 'jsArrays') {
|
||||
if (type === 'array elements') return 'Array elements';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'typedArrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'systemObjects') {
|
||||
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'otherJsObjects') {
|
||||
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
}
|
||||
|
||||
if (category === 'otherNonJsObjects') {
|
||||
if (type === 'extra native bytes') return 'Extra native bytes';
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'code') {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, topN);
|
||||
const otherValue = entries
|
||||
.slice(topN)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const collapsed = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export function collapseHeapSnapshotBreakdowns(
|
||||
breakdowns: Partial<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const categoryBreakdown = breakdowns[category];
|
||||
if (categoryBreakdown == null) continue;
|
||||
|
||||
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
|
||||
if (Object.keys(collapsedCategory).length > 0) {
|
||||
collapsed[category] = collapsedCategory;
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
|
||||
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const edges = snapshot?.edges;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
const edgeFields = meta.edge_fields;
|
||||
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
const edgeCountOffset = nodeFields.indexOf('edge_count');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
const edgeTypeOffset = edgeFields.indexOf('type');
|
||||
const edgeNameOffset = edgeFields.indexOf('name_or_index');
|
||||
const edgeToNodeOffset = edgeFields.indexOf('to_node');
|
||||
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required edge fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
|
||||
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
|
||||
|
||||
const nodeFieldCount = nodeFields.length;
|
||||
const edgeFieldCount = edgeFields.length;
|
||||
const nativeType = nodeTypeNames.indexOf('native');
|
||||
const codeType = nodeTypeNames.indexOf('code');
|
||||
const hiddenType = nodeTypeNames.indexOf('hidden');
|
||||
const stringTypes = new Set([
|
||||
nodeTypeNames.indexOf('string'),
|
||||
nodeTypeNames.indexOf('concatenated string'),
|
||||
nodeTypeNames.indexOf('sliced string'),
|
||||
]);
|
||||
const internalEdgeType = edgeTypeNames.indexOf('internal');
|
||||
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
|
||||
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
|
||||
const breakdowns = {} as Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdowns[category] = {};
|
||||
}
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
let edgeIndex = 0;
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
edgeStartIndexes.set(nodeIndex, edgeIndex);
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
|
||||
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
|
||||
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
|
||||
if (value <= 0) return;
|
||||
categories[category] += value;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
|
||||
if (nodeIndex != null) nodeCounts[category]++;
|
||||
}
|
||||
|
||||
function addJsArrayElementSize(nodeIndex: number) {
|
||||
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
|
||||
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
|
||||
if (edgeType !== internalEdgeType) continue;
|
||||
|
||||
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
|
||||
if (edgeName !== 'elements') continue;
|
||||
|
||||
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
|
||||
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
|
||||
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
|
||||
jsArrayElementNodeIndexes.add(elementsNodeIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraNativeBytes > 0) {
|
||||
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
|
||||
}
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
categories.total += selfSize;
|
||||
nodeCounts.total++;
|
||||
|
||||
if (typeId === hiddenType) {
|
||||
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === nativeType) {
|
||||
if (name === 'system / JSArrayBufferData') {
|
||||
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
|
||||
} else {
|
||||
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === codeType) {
|
||||
addCategoryValue('code', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stringTypes.has(typeId)) {
|
||||
addCategoryValue('strings', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'Array') {
|
||||
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
|
||||
addJsArrayElementSize(nodeIndex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
categories.total += extraNativeBytes;
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
|
||||
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
|
||||
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
if (name === 'Array') continue;
|
||||
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
|
||||
};
|
||||
}
|
||||
|
||||
function finiteMedian(values: (number | null | undefined)[]) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return null;
|
||||
return util.median(finiteValues);
|
||||
}
|
||||
|
||||
export function summarizeHeapSnapshotDataSamples<T>(
|
||||
samples: T[],
|
||||
getData: (sample: T) => HeapSnapshotData | null | undefined,
|
||||
options: { breakdownTopN?: number } = {},
|
||||
) {
|
||||
const data = samples.map(getData);
|
||||
const categories = {} as HeapSnapshotData['categories'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
|
||||
if (value != null) categories[category] = value;
|
||||
}
|
||||
|
||||
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
|
||||
if (value != null) nodeCounts[category] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(categories).length === 0) return null;
|
||||
|
||||
const breakdowns = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const snapshot of data) {
|
||||
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
|
||||
if (value != null) categoryBreakdown[childKey] = value;
|
||||
}
|
||||
|
||||
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
|
||||
if (Object.keys(collapsed).length > 0) {
|
||||
breakdowns[category] = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
|
||||
return report.summary.categories[category];
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type MemoryReport = {
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots');
|
||||
const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot');
|
||||
|
||||
@@ -70,51 +70,6 @@ async function resetState(repoDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) {
|
||||
const breakdowns = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, Record<string, number>>;
|
||||
|
||||
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) categoryBreakdown[childKey] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(categoryBreakdown).length > 0) {
|
||||
breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown);
|
||||
}
|
||||
}
|
||||
|
||||
return breakdowns;
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const collapsed = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
@@ -135,33 +90,12 @@ function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotCategoryValues = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
|
||||
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.categories?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshotNodeCountValues = {} as Record<keyof typeof heapSnapshotUtil.heapSnapshotCategory, number>;
|
||||
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
|
||||
const values = samples
|
||||
.map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
|
||||
if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values);
|
||||
}
|
||||
|
||||
if (Object.keys(heapSnapshotCategoryValues).length > 0) {
|
||||
const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase);
|
||||
|
||||
summary[phase].heapSnapshot = {
|
||||
categories: heapSnapshotCategoryValues,
|
||||
nodeCounts: heapSnapshotNodeCountValues,
|
||||
...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}),
|
||||
};
|
||||
}
|
||||
const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.phases[phase].heapSnapshot,
|
||||
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
|
||||
);
|
||||
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
|
||||
}
|
||||
|
||||
return summary;
|
||||
|
||||
274
.github/scripts/measure-frontend-browser-comparison.mts
vendored
Normal file
274
.github/scripts/measure-frontend-browser-comparison.mts
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import { HeadlessChromeController, summarizeNetwork } from './chrome.mts';
|
||||
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
|
||||
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2);
|
||||
|
||||
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
|
||||
const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1);
|
||||
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots');
|
||||
|
||||
type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
networkRequests: NetworkRequest[];
|
||||
};
|
||||
|
||||
type BrowserMetricsReport = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
sampleCount: number;
|
||||
aggregation: 'median';
|
||||
summary: BrowserMeasurement;
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
|
||||
async function runSignupAndPostScenario(chrome: HeadlessChromeController) {
|
||||
const page = chrome.page;
|
||||
const noteText = `Frontend browser metrics ${Date.now()}`;
|
||||
|
||||
await visitHome(page, baseUrl);
|
||||
await signupThroughUi(page, { username: 'alice', password: 'password' });
|
||||
await closeUserSetupDialog(page);
|
||||
await postNote(page, noteText, 10_000);
|
||||
|
||||
await util.sleep(1000);
|
||||
}
|
||||
|
||||
function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return defaultValue;
|
||||
return util.median(finiteValues);
|
||||
}
|
||||
|
||||
function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
|
||||
const medianValue = finiteMedian(samples.map(getValue));
|
||||
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
|
||||
|
||||
for (const sample of samples) {
|
||||
const value = getValue(sample);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
const distance = Math.abs(value - medianValue);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
|
||||
selected = {
|
||||
sample,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.sample ?? samples[0];
|
||||
}
|
||||
|
||||
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
|
||||
return {
|
||||
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)),
|
||||
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)),
|
||||
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
|
||||
const resourceTypes = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const resourceType of Object.keys(sample.network.byResourceType)) {
|
||||
resourceTypes.add(resourceType);
|
||||
}
|
||||
}
|
||||
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
for (const resourceType of resourceTypes) {
|
||||
byResourceType[resourceType] = summarizeResourceType(samples, resourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)),
|
||||
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)),
|
||||
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)),
|
||||
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)),
|
||||
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)),
|
||||
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)),
|
||||
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)),
|
||||
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)),
|
||||
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)),
|
||||
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)),
|
||||
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)),
|
||||
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)),
|
||||
byResourceType,
|
||||
largestRequests: representative.network.largestRequests,
|
||||
failedRequests: representative.network.failedRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] {
|
||||
const cdpMetricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
for (const key of cdpMetricKeys) {
|
||||
cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]));
|
||||
}
|
||||
|
||||
const webVitalKeys = [
|
||||
'firstPaintMs',
|
||||
'firstContentfulPaintMs',
|
||||
'domContentLoadedEventEndMs',
|
||||
'loadEventEndMs',
|
||||
'longTaskCount',
|
||||
'longTaskDurationMs',
|
||||
'maxLongTaskDurationMs',
|
||||
'resourceEntryCount',
|
||||
'domElements',
|
||||
] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[];
|
||||
|
||||
const webVitals = {} as BrowserMeasurement['performance']['webVitals'];
|
||||
for (const key of webVitalKeys) {
|
||||
webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]));
|
||||
}
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap: {
|
||||
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)),
|
||||
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)),
|
||||
},
|
||||
tabMemory: {
|
||||
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)),
|
||||
},
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) {
|
||||
const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.heapSnapshot,
|
||||
{ breakdownTopN: heapSnapshotBreakdownTopN },
|
||||
);
|
||||
if (summary == null) throw new Error('No heap snapshot samples');
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport {
|
||||
if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`);
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const summary: BrowserMeasurement = {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: representative.scenario,
|
||||
durationMs: finiteMedian(samples.map(sample => sample.durationMs)),
|
||||
network: summarizeNetworkSamples(samples),
|
||||
performance: summarizePerformanceSamples(samples),
|
||||
heapSnapshot: summarizeHeapSnapshotSamples(samples),
|
||||
};
|
||||
|
||||
return {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: representative.scenario,
|
||||
sampleCount: samples.length,
|
||||
aggregation: 'median',
|
||||
summary,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) {
|
||||
await util.prepareInstance(baseUrl);
|
||||
|
||||
return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => {
|
||||
await chrome.enableNetworkTracking();
|
||||
|
||||
const startedAt = Date.now();
|
||||
await runSignupAndPostScenario(chrome);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await chrome.waitForNetworkDetails();
|
||||
const performance = await chrome.collectPerformance();
|
||||
const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath);
|
||||
const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN });
|
||||
const measurement: BrowserMeasurementSample = {
|
||||
label,
|
||||
round,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
|
||||
durationMs,
|
||||
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
|
||||
networkRequests: chrome.networkRequests,
|
||||
performance,
|
||||
heapSnapshot,
|
||||
};
|
||||
|
||||
return measurement;
|
||||
});
|
||||
}
|
||||
|
||||
function headHeapSnapshotPath(round: number) {
|
||||
return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) {
|
||||
const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total);
|
||||
await copyFile(headHeapSnapshotPath(representative.round), outputPath);
|
||||
process.stderr.write(`[head] Selected round ${representative.round} heap snapshot for artifact\n`);
|
||||
await rm(headHeapSnapshotWorkDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) {
|
||||
let server: ReturnType<typeof util.startServer> | null = null;
|
||||
|
||||
try {
|
||||
server = util.startServer(label, repoDir);
|
||||
await util.waitForServer(baseUrl, server!);
|
||||
|
||||
if (label === 'head' && heapSnapshotSavePath != null) {
|
||||
await rm(headHeapSnapshotWorkDir, { recursive: true, force: true });
|
||||
await mkdir(headHeapSnapshotWorkDir, { recursive: true });
|
||||
}
|
||||
|
||||
const samples: BrowserMeasurementSample[] = [];
|
||||
for (let round = 1; round <= sampleCount; round++) {
|
||||
process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`);
|
||||
samples.push(await measureSample(
|
||||
label,
|
||||
round,
|
||||
label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined,
|
||||
));
|
||||
}
|
||||
|
||||
const report = summarizeSamples(label, samples);
|
||||
await writeFile(outputPath, JSON.stringify(report, null, '\t'));
|
||||
process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`);
|
||||
|
||||
if (label === 'head' && heapSnapshotSavePath != null) {
|
||||
await saveRepresentativeHeadHeapSnapshot(report, heapSnapshotSavePath);
|
||||
}
|
||||
} finally {
|
||||
if (server != null) await util.stopServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await measureRepo('base', resolve(baseDirArg), resolve(baseOutputArg));
|
||||
await measureRepo('head', resolve(headDirArg), resolve(headOutputArg), headHeapSnapshotOutputArg == null ? undefined : resolve(headHeapSnapshotOutputArg));
|
||||
}
|
||||
|
||||
await main();
|
||||
94
.github/scripts/utility.mts
vendored
94
.github/scripts/utility.mts
vendored
@@ -5,10 +5,14 @@
|
||||
|
||||
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
export function median(values: number[]) {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const center = Math.floor(sorted.length / 2);
|
||||
@@ -202,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function startServer(label: string, repoDir: string) {
|
||||
process.stderr.write(`[${label}] Starting Misskey test server\n`);
|
||||
const child = spawn(commandName('pnpm'), ['start:test'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/`, { redirect: 'manual' });
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${baseUrl}`);
|
||||
}
|
||||
|
||||
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
|
||||
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function prepareInstance(baseUrl: string) {
|
||||
await api(baseUrl, 'reset-db', {});
|
||||
await api(baseUrl, 'admin/accounts/create', {
|
||||
username: 'admin',
|
||||
password: 'admin1234',
|
||||
setupPassword: 'example_password_please_change_this_or_you_will_get_hacked',
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopServer(child: ChildProcessWithoutNullStreams) {
|
||||
if (child.exitCode != null) return;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else if (child.pid != null) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>(resolvePromise => {
|
||||
if (child.exitCode != null) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
child.once('exit', () => resolvePromise());
|
||||
setTimeout(() => {
|
||||
if (child.pid != null) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
resolvePromise();
|
||||
}, 10_000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
44
.github/workflows/frontend-browser-metrics-report-comment.yml
vendored
Normal file
44
.github/workflows/frontend-browser-metrics-report-comment.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: frontend-browser-metrics-report-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- frontend-browser-metrics-report
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend browser metrics report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download browser metrics report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-browser-metrics-report
|
||||
path: ${{ runner.temp }}/frontend-browser-metrics-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend_browser_metrics_report
|
||||
file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md
|
||||
198
.github/workflows/frontend-browser-metrics-report.yml
vendored
Normal file
198
.github/workflows/frontend-browser-metrics-report.yml
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
name: frontend-browser-metrics-report
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/backend/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/misskey/test.yml
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-browser-detailed-html.mts
|
||||
- .github/scripts/frontend-browser-report.mts
|
||||
- .github/scripts/heap-snapshot-util.mts
|
||||
- .github/scripts/measure-frontend-browser-comparison.mts
|
||||
- .github/scripts/chrome.mts
|
||||
- .github/workflows/frontend-browser-metrics-report.yml
|
||||
- .github/workflows/frontend-browser-metrics-report-comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-browser-metrics-report-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Measure frontend browser metrics
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure base
|
||||
working-directory: before
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build base
|
||||
working-directory: before
|
||||
run: pnpm build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure pull request
|
||||
working-directory: after
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build pull request
|
||||
working-directory: after
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: after/packages/frontend
|
||||
run: pnpm exec playwright install --with-deps --no-shell chromium
|
||||
|
||||
- name: Measure frontend browser metrics
|
||||
shell: bash
|
||||
env:
|
||||
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
|
||||
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot"
|
||||
|
||||
- name: Upload browser head heap snapshot
|
||||
id: upload-browser-head-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-head-heap-snapshot
|
||||
path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
|
||||
test -s "$REPORT_DIR/before-browser.json"
|
||||
test -s "$REPORT_DIR/after-browser.json"
|
||||
node after/.github/scripts/frontend-browser-detailed-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
|
||||
- name: Upload browser detailed html
|
||||
id: upload-browser-detailed-html
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-detailed-html
|
||||
path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-detailed-html.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser metrics report
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
|
||||
test -s "$REPORT_DIR/before-browser.json"
|
||||
test -s "$REPORT_DIR/after-browser.json"
|
||||
node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check browser metrics report
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
|
||||
test -s "$REPORT_DIR/frontend-browser-metrics-report.md"
|
||||
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
test -s "$REPORT_DIR/pr-number.txt"
|
||||
test -s "$REPORT_DIR/head-sha.txt"
|
||||
cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload browser metrics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-report
|
||||
path: |
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -21,15 +21,6 @@
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
|
||||
- 操作性の改善
|
||||
- ビューワーとMisskeyの各種機能との統合を強化
|
||||
- パフォーマンスの向上
|
||||
- Enhance: マウスホイールで拡大縮小できるように
|
||||
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
|
||||
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
|
||||
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
|
||||
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
|
||||
|
||||
@@ -3,7 +3,6 @@ import { version as summalyVersion } from '@misskey-dev/summaly';
|
||||
import type { Plugin, ExternalOption } from 'rolldown';
|
||||
import { execa, execaNode } from 'execa';
|
||||
import type { ResultPromise } from 'execa';
|
||||
import fkill from 'fkill';
|
||||
import esmShim from '@rollup/plugin-esm-shim';
|
||||
|
||||
/**
|
||||
@@ -11,7 +10,6 @@ import esmShim from '@rollup/plugin-esm-shim';
|
||||
*/
|
||||
function backendDevServerPlugin(): Plugin {
|
||||
let backendProcess: ResultPromise | null = null;
|
||||
let backendShutdownPromise: Promise<void> | null = null;
|
||||
|
||||
async function runBuildAssets() {
|
||||
await execa('pnpm', ['run', 'build-assets'], {
|
||||
@@ -22,31 +20,12 @@ function backendDevServerPlugin(): Plugin {
|
||||
}
|
||||
|
||||
async function killBackendProcess() {
|
||||
if (backendShutdownPromise) return backendShutdownPromise;
|
||||
if (!backendProcess) return;
|
||||
|
||||
const processToKill = backendProcess;
|
||||
backendProcess = null;
|
||||
processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す
|
||||
|
||||
backendShutdownPromise = (async () => {
|
||||
if (process.platform === 'win32' && processToKill.pid != null) {
|
||||
await fkill(processToKill.pid, {
|
||||
force: true,
|
||||
tree: true,
|
||||
silent: true,
|
||||
waitForExit: 5000,
|
||||
});
|
||||
} else {
|
||||
processToKill.kill();
|
||||
}
|
||||
|
||||
await processToKill.catch(() => {});
|
||||
})().finally(() => {
|
||||
backendShutdownPromise = null;
|
||||
});
|
||||
|
||||
return backendShutdownPromise;
|
||||
if (backendProcess) {
|
||||
backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す
|
||||
backendProcess.kill();
|
||||
await new Promise(resolve => backendProcess!.on('exit', resolve));
|
||||
backendProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -70,9 +49,6 @@ function backendDevServerPlugin(): Plugin {
|
||||
await runBuildAssets();
|
||||
}
|
||||
},
|
||||
async closeWatcher() {
|
||||
await killBackendProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
//import * as http from 'node:http';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
|
||||
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -40,7 +40,7 @@ const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Tim
|
||||
const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0);
|
||||
const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||
const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1);
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1);
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
|
||||
|
||||
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
@@ -79,246 +79,6 @@ function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) {
|
||||
if (category === 'strings') return type;
|
||||
|
||||
if (category === 'jsArrays') {
|
||||
if (type === 'array elements') return 'Array elements';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'typedArrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'systemObjects') {
|
||||
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'otherJsObjects') {
|
||||
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
}
|
||||
|
||||
if (category === 'otherNonJsObjects') {
|
||||
if (type === 'extra native bytes') return 'Extra native bytes';
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'code') {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
function collapseHeapSnapshotBreakdown(breakdowns: Record<string, Record<string, number>>) {
|
||||
const collapsed = {} as Record<string, Record<string, number>>;
|
||||
|
||||
for (const [category, children] of Object.entries(breakdowns)) {
|
||||
const entries = Object.entries(children)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N);
|
||||
const otherValue = entries
|
||||
.slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const categoryBreakdown = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) categoryBreakdown.Other = otherValue;
|
||||
if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown;
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
|
||||
function analyzeHeapSnapshot(snapshot) {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const edges = snapshot?.edges;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
const edgeFields = meta.edge_fields;
|
||||
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
const edgeCountOffset = nodeFields.indexOf('edge_count');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
const edgeTypeOffset = edgeFields.indexOf('type');
|
||||
const edgeNameOffset = edgeFields.indexOf('name_or_index');
|
||||
const edgeToNodeOffset = edgeFields.indexOf('to_node');
|
||||
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required edge fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
|
||||
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
|
||||
|
||||
function createEmptyHeapSnapshotCategoryMap() {
|
||||
return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record<keyof typeof heapSnapshotCategory, number>;
|
||||
}
|
||||
|
||||
const nodeFieldCount = nodeFields.length;
|
||||
const edgeFieldCount = edgeFields.length;
|
||||
const nativeType = nodeTypeNames.indexOf('native');
|
||||
const codeType = nodeTypeNames.indexOf('code');
|
||||
const hiddenType = nodeTypeNames.indexOf('hidden');
|
||||
const stringTypes = new Set([
|
||||
nodeTypeNames.indexOf('string'),
|
||||
nodeTypeNames.indexOf('concatenated string'),
|
||||
nodeTypeNames.indexOf('sliced string'),
|
||||
]);
|
||||
const internalEdgeType = edgeTypeNames.indexOf('internal');
|
||||
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
|
||||
const categories = createEmptyHeapSnapshotCategoryMap();
|
||||
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
|
||||
const breakdowns = Object.fromEntries(
|
||||
(Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
|
||||
.filter(category => category !== 'total')
|
||||
.map(category => [category, {}]),
|
||||
);
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
let edgeIndex = 0;
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
edgeStartIndexes.set(nodeIndex, edgeIndex);
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
|
||||
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
|
||||
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
|
||||
if (value <= 0) return;
|
||||
categories[category] += value;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
|
||||
if (nodeIndex != null) nodeCounts[category]++;
|
||||
}
|
||||
|
||||
function addJsArrayElementSize(nodeIndex: number) {
|
||||
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
|
||||
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
|
||||
if (edgeType !== internalEdgeType) continue;
|
||||
|
||||
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
|
||||
if (edgeName !== 'elements') continue;
|
||||
|
||||
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
|
||||
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
|
||||
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
|
||||
jsArrayElementNodeIndexes.add(elementsNodeIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraNativeBytes > 0) {
|
||||
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
|
||||
}
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
categories.total += selfSize;
|
||||
nodeCounts.total++;
|
||||
|
||||
if (typeId === hiddenType) {
|
||||
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === nativeType) {
|
||||
if (name === 'system / JSArrayBufferData') {
|
||||
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
|
||||
} else {
|
||||
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === codeType) {
|
||||
addCategoryValue('code', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stringTypes.has(typeId)) {
|
||||
addCategoryValue('strings', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'Array') {
|
||||
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
|
||||
addJsArrayElementSize(nodeIndex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
categories.total += extraNativeBytes;
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
|
||||
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
|
||||
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
if (name === 'Array') continue;
|
||||
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdown(breakdowns),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/status`;
|
||||
const status = await fs.readFile(path, 'utf-8');
|
||||
@@ -417,7 +177,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
|
||||
}
|
||||
|
||||
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||
return analyzeHeapSnapshot(snapshot);
|
||||
return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N });
|
||||
} finally {
|
||||
await fs.unlink(writtenPath).catch(err => {
|
||||
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
|
||||
|
||||
@@ -61,6 +61,7 @@ it('Composition API (standard)', () => {
|
||||
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
|
||||
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
||||
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
@@ -178,6 +179,7 @@ export { index_photos as default };
|
||||
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
|
||||
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
|
||||
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
|
||||
import './photoswipe-!~{003}~.js';
|
||||
|
||||
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
|
||||
const index_photos = /* @__PURE__ */ defineComponent({
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
v-show="show"
|
||||
ref="canvas"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
draggable="false"
|
||||
tabindex="-1"
|
||||
style="-webkit-user-drag: none;"
|
||||
></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
|
||||
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
|
||||
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
if (isTest) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const testWorker = new TestWebGL2();
|
||||
testWorker.addEventListener('message', event => {
|
||||
if (event.data.result) {
|
||||
const workers = new WorkerMultiDispatch(
|
||||
() => new DrawBlurhash(),
|
||||
Math.min(navigator.hardwareConcurrency - 1, 4),
|
||||
);
|
||||
resolve(workers);
|
||||
} else {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
}
|
||||
testWorker.terminate();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref, shallowRef, useTemplateRef, onMounted, onUnmounted } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
blurhash: string | null;
|
||||
onlyAvgColor?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// v-showが何故か動作しないため
|
||||
show?: boolean;
|
||||
}>(), {
|
||||
onlyAvgColor: false,
|
||||
width: 64,
|
||||
height: 64,
|
||||
show: true,
|
||||
});
|
||||
|
||||
const canvas = useTemplateRef('canvas');
|
||||
const canvasWidth = ref(64);
|
||||
const canvasHeight = ref(64);
|
||||
const viewId = genId();
|
||||
const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
|
||||
|
||||
watch([() => props.width, () => props.height, canvas], () => {
|
||||
const ratio = props.width / props.height;
|
||||
if (ratio > 1) {
|
||||
canvasWidth.value = Math.round(64 * ratio);
|
||||
canvasHeight.value = 64;
|
||||
} else {
|
||||
canvasWidth.value = 64;
|
||||
canvasHeight.value = Math.round(64 / ratio);
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
watch(() => props.blurhash, () => {
|
||||
draw();
|
||||
});
|
||||
|
||||
function drawImage(bitmap: CanvasImageSource) {
|
||||
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||
if (!canvas.value) {
|
||||
bitmapTmp.value = bitmap;
|
||||
return;
|
||||
}
|
||||
|
||||
// canvasがあれば描画する
|
||||
bitmapTmp.value = undefined;
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
function drawAvg() {
|
||||
if (!canvas.value) return;
|
||||
|
||||
const color = (props.blurhash != null && extractAvgColorFromBlurhash(props.blurhash)) || '#888';
|
||||
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// avgColorでお茶をにごす
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (isTest && props.blurhash == null) return;
|
||||
|
||||
drawAvg();
|
||||
|
||||
if (props.blurhash == null) return;
|
||||
|
||||
if (props.onlyAvgColor) return;
|
||||
|
||||
const work = await canvasPromise;
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.postMessage(
|
||||
{
|
||||
id: viewId,
|
||||
hash: props.blurhash,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { render } = await import('buraha');
|
||||
render(props.blurhash, work);
|
||||
drawImage(work);
|
||||
} catch (error) {
|
||||
console.error('Error occurred during drawing blurhash', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workerOnMessage(event: MessageEvent) {
|
||||
if (event.data.id !== viewId) return;
|
||||
drawImage(event.data.bitmap as ImageBitmap);
|
||||
}
|
||||
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.addListener(workerOnMessage);
|
||||
}
|
||||
|
||||
draw();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||
if (bitmapTmp.value) {
|
||||
drawImage(bitmapTmp.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.removeListener(workerOnMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,39 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_gaps_s">
|
||||
<MkKeyValue v-if="content.filename != null">
|
||||
<template #key>{{ i18n.ts.fileName }}</template>
|
||||
<template #value>{{ content.filename }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue v-if="content.file != null">
|
||||
<template #key>{{ i18n.ts.description }}</template>
|
||||
<template #value>
|
||||
<div :class="$style.pre">{{ content.file.comment ? content.file.comment : `(${i18n.ts.none})` }}</div>
|
||||
</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import type { Content } from '@/components/MkImageGallery.item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
content: Content;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,303 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- durationは子Itemコンポーネントがフェードイン/アウトするdurationと合わせる -->
|
||||
<Transition
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_root_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_root_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_root_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_root_leaveTo : ''"
|
||||
:duration="{ enter: prefer.s.animation ? openAnimDuration : 0, leave: prefer.s.animation ? closeAnimDuration : 0 }"
|
||||
appear
|
||||
@afterLeave="onAfterLeave"
|
||||
>
|
||||
<!-- v-ifを使うとfalseになったとき(transitionが行われている間)子コンポーネントの更新が停止するのか子コンポーネントがアニメーションされなくなる -->
|
||||
<div v-show="showing" ref="rootEl" v-hotkey.global="keymap" :class="$style.root" :style="{ zIndex }">
|
||||
<div :class="[$style.bg]" class="_modalBg"></div>
|
||||
<div ref="mainEl" :class="$style.main">
|
||||
<div
|
||||
ref="itemsEl"
|
||||
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
|
||||
:style="{ translate: `${contentsOffset}px 0` }"
|
||||
@transitionend.self="onSlideTransitionFinished"
|
||||
@transitioncancel.self="onSlideTransitionFinished"
|
||||
>
|
||||
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
|
||||
<XItem
|
||||
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
|
||||
:content="content"
|
||||
:initiallyOpened="i === (props.defaultIndex ?? 0)"
|
||||
:activated="activatedIndexes.has(i)"
|
||||
@close="onItemClose"
|
||||
@horizontalSwipe="onHorizontalSwipe"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@cancelHorizontalSwipe="onCancelHorizontalSwipe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!isTouchUsing && currentIndex > 0" class="_button" :class="[$style.prevButton]" @click="onPrev"><div :class="$style.buttonIcon"><i class="ti ti-arrow-left"></i></div></button>
|
||||
<button v-if="!isTouchUsing && currentIndex < contents.length - 1" class="_button" :class="[$style.nextButton]" @click="onNext"><div :class="$style.buttonIcon"><i class="ti ti-arrow-right"></i></div></button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
|
||||
import XItem from './MkImageGallery.item.vue';
|
||||
import type { Content } from './MkImageGallery.item.vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { isTouchUsing } from '@/utility/touch.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultIndex?: number;
|
||||
contents: Content[];
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const activatedIndexes = ref(new Set<number>());
|
||||
const items = new Map<number, InstanceType<typeof XItem>>();
|
||||
const currentIndex = ref(props.defaultIndex ?? 0);
|
||||
|
||||
watch(currentIndex, (newIndex, oldIndex) => {
|
||||
activatedIndexes.value.add(newIndex);
|
||||
|
||||
nextTick(() => {
|
||||
if (oldIndex != null && items.has(oldIndex)) {
|
||||
items.get(oldIndex)!.onDeactive();
|
||||
}
|
||||
if (items.has(newIndex)) {
|
||||
items.get(newIndex)!.onActive();
|
||||
}
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(currentIndex, (newIndex) => {
|
||||
for (let i = 0; i < props.contents.length; i++) {
|
||||
const content = props.contents[i];
|
||||
if (content.sourceElement != null) {
|
||||
content.sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
|
||||
}
|
||||
}
|
||||
}, { immediate: false });
|
||||
|
||||
const openAnimDuration = 200;
|
||||
const closeAnimDuration = 200;
|
||||
const slideAnimDuration = 300;
|
||||
const zIndex = os.claimZIndex('high');
|
||||
const showing = ref(true);
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const contentsOffset = ref(currentIndex.value * -window.innerWidth);
|
||||
const enableSlideTransition = ref(false);
|
||||
let currentScrollLeft = contentsOffset.value;
|
||||
|
||||
function onResize() {
|
||||
screenWidth.value = window.innerWidth;
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
|
||||
function onHorizontalSwipe(offset: number) {
|
||||
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
|
||||
contentsOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else if (currentIndex.value === props.contents.length - 1 && offset < 0) { // これ以上進めない
|
||||
contentsOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else {
|
||||
contentsOffset.value = currentScrollLeft + offset;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrentIndex() {
|
||||
const targetOffset = currentIndex.value * -screenWidth.value;
|
||||
currentScrollLeft = targetOffset;
|
||||
|
||||
if (!prefer.s.animation || contentsOffset.value === targetOffset) {
|
||||
enableSlideTransition.value = false;
|
||||
contentsOffset.value = targetOffset;
|
||||
return;
|
||||
}
|
||||
|
||||
enableSlideTransition.value = true;
|
||||
contentsOffset.value = targetOffset;
|
||||
}
|
||||
|
||||
/** ギャラリーそのものを閉じるための内部処理(閉じる処理を書く場合は `close` か、item内では `closeThis` を使う) */
|
||||
function closeGallery() {
|
||||
showing.value = false;
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (items.has(currentIndex.value)) {
|
||||
items.get(currentIndex.value)!.closeThis();
|
||||
} else {
|
||||
closeGallery();
|
||||
}
|
||||
}
|
||||
|
||||
function onSlideTransitionFinished(ev: TransitionEvent) {
|
||||
if (ev.propertyName !== 'translate') return;
|
||||
enableSlideTransition.value = false;
|
||||
}
|
||||
|
||||
function onCancelHorizontalSwipe() {
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
if (currentIndex.value < props.contents.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onPrev() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onItemClose() {
|
||||
closeGallery();
|
||||
}
|
||||
|
||||
function onAfterLeave() {
|
||||
for (const content of props.contents) {
|
||||
if (content.sourceElement != null) {
|
||||
content.sourceElement.style.visibility = '';
|
||||
}
|
||||
}
|
||||
emit('closed');
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
if (showing.value) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.history.pushState(null, '', '#pswp');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
const keymap = {
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => onItemClose(),
|
||||
},
|
||||
'arrowleft': {
|
||||
allowRepeat: true,
|
||||
callback: () => onPrev(),
|
||||
},
|
||||
'arrowright': {
|
||||
allowRepeat: true,
|
||||
callback: () => onNext(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_root_enterActive,
|
||||
.transition_root_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity v-bind("closeAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
|
||||
}
|
||||
}
|
||||
.transition_root_enterFrom,
|
||||
.transition_root_leaveTo {
|
||||
pointer-events: none;
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bg {
|
||||
}
|
||||
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: calc(v-bind("screenWidth + 'px'") * v-bind("contents.length"));
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.itemsTransition {
|
||||
pointer-events: none;
|
||||
transition: translate v-bind("slideAnimDuration + 'ms'") cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prevButton,
|
||||
.nextButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 70px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.prevButton {
|
||||
left: 0;
|
||||
}
|
||||
.nextButton {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 100%;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -14,15 +14,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
|
||||
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
|
||||
>
|
||||
<MkBlurhash
|
||||
<canvas
|
||||
v-show="hide"
|
||||
key="canvas"
|
||||
ref="canvas"
|
||||
:class="$style.canvas"
|
||||
:blurhash="hash ?? null"
|
||||
:height="imgHeight ?? undefined"
|
||||
:width="imgWidth ?? undefined"
|
||||
:onlyAvgColor="props.onlyAvgColor"
|
||||
:show="hide"
|
||||
/>
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
:title="title ?? undefined"
|
||||
draggable="false"
|
||||
tabindex="-1"
|
||||
style="-webkit-user-drag: none;"
|
||||
></canvas>
|
||||
<img
|
||||
v-show="!hide"
|
||||
key="img"
|
||||
@@ -33,7 +36,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:src="src ?? undefined"
|
||||
:title="title ?? undefined"
|
||||
:alt="alt ?? undefined"
|
||||
:data-marker="marker ?? undefined"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@@ -44,10 +46,50 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
|
||||
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
if (isTest) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const testWorker = new TestWebGL2();
|
||||
testWorker.addEventListener('message', event => {
|
||||
if (event.data.result) {
|
||||
const workers = new WorkerMultiDispatch(
|
||||
() => new DrawBlurhash(),
|
||||
Math.min(navigator.hardwareConcurrency - 1, 4),
|
||||
);
|
||||
resolve(workers);
|
||||
} else {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
}
|
||||
testWorker.terminate();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { render } from 'buraha';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import MkBlurhash from '@/components/MkBlurhash.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
transition?: {
|
||||
@@ -68,7 +110,6 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
|
||||
marker?: string;
|
||||
}>(), {
|
||||
transition: null,
|
||||
src: null,
|
||||
@@ -81,11 +122,16 @@ const props = withDefaults(defineProps<{
|
||||
onlyAvgColor: false,
|
||||
});
|
||||
|
||||
const viewId = genId();
|
||||
const canvas = useTemplateRef('canvas');
|
||||
const root = useTemplateRef('root');
|
||||
const img = useTemplateRef('img');
|
||||
const loaded = ref(false);
|
||||
const canvasWidth = ref(64);
|
||||
const canvasHeight = ref(64);
|
||||
const imgWidth = ref(props.width);
|
||||
const imgHeight = ref(props.height);
|
||||
const bitmapTmp = ref<CanvasImageSource | undefined>();
|
||||
const hide = computed(() => !loaded.value || props.forceBlurhash);
|
||||
|
||||
function waitForDecode() {
|
||||
@@ -104,6 +150,14 @@ function waitForDecode() {
|
||||
|
||||
watch([() => props.width, () => props.height, root], () => {
|
||||
const ratio = props.width / props.height;
|
||||
if (ratio > 1) {
|
||||
canvasWidth.value = Math.round(64 * ratio);
|
||||
canvasHeight.value = 64;
|
||||
} else {
|
||||
canvasWidth.value = 64;
|
||||
canvasHeight.value = Math.round(64 / ratio);
|
||||
}
|
||||
|
||||
const clientWidth = root.value?.clientWidth ?? 300;
|
||||
imgWidth.value = clientWidth;
|
||||
imgHeight.value = Math.round(clientWidth / ratio);
|
||||
@@ -111,10 +165,97 @@ watch([() => props.width, () => props.height, root], () => {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function drawImage(bitmap: CanvasImageSource) {
|
||||
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||
if (!canvas.value) {
|
||||
bitmapTmp.value = bitmap;
|
||||
return;
|
||||
}
|
||||
|
||||
// canvasがあれば描画する
|
||||
bitmapTmp.value = undefined;
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
function drawAvg() {
|
||||
if (!canvas.value) return;
|
||||
|
||||
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
|
||||
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// avgColorでお茶をにごす
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (isTest && props.hash == null) return;
|
||||
|
||||
drawAvg();
|
||||
|
||||
if (props.hash == null) return;
|
||||
|
||||
if (props.onlyAvgColor) return;
|
||||
|
||||
const work = await canvasPromise;
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.postMessage(
|
||||
{
|
||||
id: viewId,
|
||||
hash: props.hash,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
render(props.hash, work);
|
||||
drawImage(work);
|
||||
} catch (error) {
|
||||
console.error('Error occurred during drawing blurhash', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workerOnMessage(event: MessageEvent) {
|
||||
if (event.data.id !== viewId) return;
|
||||
drawImage(event.data.bitmap as ImageBitmap);
|
||||
}
|
||||
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.addListener(workerOnMessage);
|
||||
}
|
||||
|
||||
draw();
|
||||
});
|
||||
|
||||
watch(() => props.src, () => {
|
||||
waitForDecode();
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
watch(() => props.hash, () => {
|
||||
draw();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||
if (bitmapTmp.value) {
|
||||
drawImage(bitmapTmp.value);
|
||||
}
|
||||
waitForDecode();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.removeListener(workerOnMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
tabindex="-1"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
|
||||
<i v-else class="ti ti-player-play-filled"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
@@ -100,7 +100,6 @@ import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -209,11 +208,56 @@ function showMenu(ev: MouseEvent) {
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
hide.value = true;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
menu.push(...getFileMenu(props.audio, (newState) => {
|
||||
hide.value = newState;
|
||||
}));
|
||||
if (iAmModerator) {
|
||||
menu.push({
|
||||
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: () => toggleSensitive(props.audio),
|
||||
});
|
||||
}
|
||||
|
||||
const details: MenuItem[] = [];
|
||||
if ($i?.id === props.audio.userId) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.audio.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.moderation,
|
||||
icon: 'ti ti-photo-exclamation',
|
||||
to: `/admin/file/${props.audio.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
menu.push({ type: 'divider' }, ...details);
|
||||
}
|
||||
|
||||
if (prefer.s.devMode) {
|
||||
menu.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(props.audio.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
@@ -224,6 +268,20 @@ function showMenu(ev: MouseEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSensitive(file: Misskey.entities.DriveFile) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('drive/files/update', {
|
||||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive,
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
|
||||
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
|
||||
<component
|
||||
:is="disableImageLink ? 'div' : 'a'"
|
||||
v-bind="disableImageLink ? {
|
||||
@@ -29,7 +29,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:height="image.properties.height"
|
||||
:style="hide ? 'filter: brightness(0.7);' : null"
|
||||
:class="$style.image"
|
||||
:marker="marker"
|
||||
/>
|
||||
<div
|
||||
v-else-if="prefer.s.dataSaver.media || hide"
|
||||
@@ -43,7 +42,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:alt="image.comment || image.name"
|
||||
:title="image.comment || image.name"
|
||||
:class="$style.image"
|
||||
:data-marker="marker"
|
||||
/>
|
||||
</component>
|
||||
<template v-if="hide">
|
||||
@@ -70,14 +68,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import { getStaticImageUrl } from '@/utility/media-proxy.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: Misskey.entities.DriveFile;
|
||||
@@ -85,17 +85,12 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
disableImageLink?: boolean;
|
||||
controls?: boolean;
|
||||
marker?: string;
|
||||
}>(), {
|
||||
cover: false,
|
||||
disableImageLink: false,
|
||||
controls: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
const hide = ref(true);
|
||||
|
||||
const url = computed(() => (props.raw || prefer.s.loadRawImages)
|
||||
@@ -105,9 +100,8 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages)
|
||||
: props.image.thumbnailUrl!,
|
||||
);
|
||||
|
||||
async function onClick(ev: PointerEvent) {
|
||||
async function reveal(ev: PointerEvent) {
|
||||
if (!props.controls) {
|
||||
emit('mediaClick', ev);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,8 +112,6 @@ async function onClick(ev: PointerEvent) {
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
} else {
|
||||
emit('mediaClick', ev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +123,80 @@ watch(() => props.image, (newImage) => {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function getMenu() {
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
hide.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
if (iAmModerator) {
|
||||
menuItems.push({
|
||||
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('drive/files/update', {
|
||||
fileId: props.image.id,
|
||||
isSensitive: !props.image.isSensitive,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const details: MenuItem[] = [];
|
||||
if ($i?.id === props.image.userId) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.image.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.moderation,
|
||||
icon: 'ti ti-photo-exclamation',
|
||||
to: `/admin/file/${props.image.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
menuItems.push({ type: 'divider' }, ...details);
|
||||
}
|
||||
|
||||
if (prefer.s.devMode) {
|
||||
menuItems.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(props.image.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
os.popupMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
|
||||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,23 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
]"
|
||||
>
|
||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||
<XVideo
|
||||
v-if="media.type.startsWith('video')"
|
||||
:key="`video:${media.id}`"
|
||||
:class="$style.media"
|
||||
:video="media"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
/>
|
||||
<XImage
|
||||
v-else-if="media.type.startsWith('image')"
|
||||
:key="`image:${media.id}`"
|
||||
:marker="`${markerId}:${media.id}`"
|
||||
:disableImageLink="true"
|
||||
:class="$style.media"
|
||||
:image="media"
|
||||
:raw="raw"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
/>
|
||||
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,16 +29,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import 'photoswipe/style.css';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { focusParent } from '@/utility/focus.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import type { Content } from '@/components/MkImageGallery.item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
@@ -61,8 +48,18 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const gallery = useTemplateRef('gallery');
|
||||
const pswpZIndex = os.claimZIndex('middle');
|
||||
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
const markerId = genId();
|
||||
let lightbox: PhotoSwipeLightbox | null = null;
|
||||
|
||||
let activeEl: HTMLElement | null = null;
|
||||
|
||||
const popstateHandler = (): void => {
|
||||
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
|
||||
lightbox.pswp.close();
|
||||
}
|
||||
};
|
||||
|
||||
async function calcAspectRatio() {
|
||||
if (!gallery.value) return;
|
||||
@@ -99,9 +96,121 @@ onMounted(() => {
|
||||
calcAspectRatio();
|
||||
|
||||
if (gallery.value == null) return; // TSを黙らすため
|
||||
|
||||
lightbox = new PhotoSwipeLightbox({
|
||||
dataSource: props.mediaList
|
||||
.filter(media => {
|
||||
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
|
||||
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
|
||||
})
|
||||
.map(media => {
|
||||
const item = {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
alt: media.comment || media.name,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
comment: media.comment || media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
gallery: gallery.value,
|
||||
mainClass: 'pswp',
|
||||
children: '.image',
|
||||
thumbSelector: '.image',
|
||||
loop: false,
|
||||
padding: window.innerWidth > 500 ? {
|
||||
top: 32,
|
||||
bottom: 90,
|
||||
left: 32,
|
||||
right: 32,
|
||||
} : {
|
||||
top: 0,
|
||||
bottom: 78,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: 'close',
|
||||
tapAction: 'close',
|
||||
bgOpacity: 1,
|
||||
showAnimationDuration: 100,
|
||||
hideAnimationDuration: 100,
|
||||
returnFocus: false,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.addFilter('itemData', (itemData) => {
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element?.dataset.id;
|
||||
const file = props.mediaList.find(media => media.id === id);
|
||||
if (!file) return itemData;
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
itemData.h = Number(file.properties.height);
|
||||
if (file.properties.orientation != null && file.properties.orientation >= 5) {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl ?? undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.alt = file.comment || file.name;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.comment = file.comment || file.name;
|
||||
itemData.thumbCropped = true;
|
||||
|
||||
return itemData;
|
||||
});
|
||||
|
||||
lightbox.on('uiRegister', () => {
|
||||
lightbox?.pswp?.ui?.registerElement({
|
||||
name: 'altText',
|
||||
className: 'pswp__alt-text-container',
|
||||
appendTo: 'wrapper',
|
||||
onInit: (el, pswp) => {
|
||||
const textBox = window.document.createElement('p');
|
||||
textBox.className = 'pswp__alt-text _acrylic';
|
||||
el.appendChild(textBox);
|
||||
|
||||
pswp.on('change', () => {
|
||||
textBox.textContent = pswp.currSlide?.data.comment;
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.on('afterInit', () => {
|
||||
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
|
||||
focusParent(activeEl, true, true);
|
||||
lightbox?.pswp?.element?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
window.history.pushState(null, '', '#pswp');
|
||||
});
|
||||
|
||||
lightbox.on('destroy', () => {
|
||||
focusParent(activeEl, true, false);
|
||||
activeEl = null;
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
lightbox.init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
lightbox?.destroy();
|
||||
lightbox = null;
|
||||
activeEl = null;
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
@@ -110,47 +219,11 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
if (prefer.s.imageNewTab) {
|
||||
window.open(file.url, '_blank');
|
||||
return;
|
||||
const openGallery = () => {
|
||||
if (props.mediaList.filter(media => previewable(media)).length > 0) {
|
||||
lightbox?.loadAndOpen(0);
|
||||
}
|
||||
openGallery(file.id);
|
||||
}
|
||||
|
||||
async function openGallery(id?: string) {
|
||||
if (id == null) {
|
||||
const firstImage = props.mediaList.find(media => previewable(media));
|
||||
if (firstImage == null) return;
|
||||
id = firstImage.id;
|
||||
}
|
||||
|
||||
const getElementByMarker = (marker: string) => {
|
||||
if (gallery.value == null) return null;
|
||||
const found = gallery.value.querySelector(`[data-marker="${marker}"]`) as HTMLElement | null;
|
||||
if (found == null) return null;
|
||||
return markRaw(found);
|
||||
};
|
||||
|
||||
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
|
||||
id: media.id,
|
||||
type: media.type.startsWith('video') ? 'video' : 'image',
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.properties.width ?? 0,
|
||||
height: media.properties.height ?? 0,
|
||||
filename: media.name,
|
||||
file: media,
|
||||
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
|
||||
}));
|
||||
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
|
||||
defaultIndex: contents.findIndex(conten => conten.id === id),
|
||||
contents: contents,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openGallery,
|
||||
@@ -243,7 +316,6 @@ defineExpose({
|
||||
.media {
|
||||
overflow: hidden; // clipにするとバグる
|
||||
border-radius: 8px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
@@ -259,4 +331,42 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.pswp) {
|
||||
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
|
||||
--pswp-bg: var(--MI_THEME-modalBg) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp__bg {
|
||||
background: var(--MI_THEME-modalBg);
|
||||
backdrop-filter: var(--MI-modalBgFilter);
|
||||
}
|
||||
|
||||
.pswp__alt-text-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: 75%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.pswp__alt-text {
|
||||
color: var(--MI_THEME-fg);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: var(--MI-margin);
|
||||
border-radius: var(--MI-radius);
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
text-shadow: var(--MI_THEME-bg) 0 0 10px, var(--MI_THEME-bg) 0 0 3px, var(--MI_THEME-bg) 0 0 3px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<!-- Media系専用のinput range -->
|
||||
<template>
|
||||
<div :class="$style.controlsSeekbar">
|
||||
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
|
||||
<div :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--MI_THEME-scrollbarHandle);'">
|
||||
<div :class="$style.controlsSeekbar">
|
||||
<progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress>
|
||||
<input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,8 +18,10 @@ import { computed } from 'vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
buffer?: number;
|
||||
sliderBgWhite?: boolean;
|
||||
}>(), {
|
||||
buffer: undefined,
|
||||
sliderBgWhite: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,8 +38,6 @@ const modelValue = computed({
|
||||
<style lang="scss" module>
|
||||
.controlsSeekbar {
|
||||
position: relative;
|
||||
--sliderBg: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
|
||||
--thumbSize: 17px;
|
||||
}
|
||||
|
||||
.seek {
|
||||
@@ -47,7 +49,7 @@ const modelValue = computed({
|
||||
border-radius: 26px;
|
||||
color: var(--MI_THEME-accent);
|
||||
display: block;
|
||||
height: 24px;
|
||||
height: 19px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
@@ -81,11 +83,11 @@ const modelValue = computed({
|
||||
border: 0;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
|
||||
height: var(--thumbSize);
|
||||
margin-top: calc((5px - var(--thumbSize)) / 2);
|
||||
height: 13px;
|
||||
margin-top: -4px;
|
||||
position: relative;
|
||||
transition: all .2s ease;
|
||||
width: var(--thumbSize);
|
||||
width: 13px;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
|
||||
@@ -97,10 +99,10 @@ const modelValue = computed({
|
||||
border: 0;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2);
|
||||
height: var(--thumbSize);
|
||||
height: 13px;
|
||||
position: relative;
|
||||
transition: all .2s ease;
|
||||
width: var(--thumbSize);
|
||||
width: 13px;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5);
|
||||
@@ -117,7 +119,7 @@ const modelValue = computed({
|
||||
.buffer {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
color: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
|
||||
color: var(--sliderBg);
|
||||
border: 0;
|
||||
border-radius: 99rem;
|
||||
height: 5px;
|
||||
|
||||
@@ -6,12 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.root,
|
||||
$style.videoContainer,
|
||||
controlsShowing && $style.active,
|
||||
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@mouseover.passive="onMouseOver"
|
||||
@mousemove.passive="onMouseMove"
|
||||
@mouseleave.passive="onMouseLeave"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="reveal">
|
||||
@@ -22,49 +27,154 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
|
||||
<img
|
||||
v-if="video.thumbnailUrl"
|
||||
:class="$style.video"
|
||||
:src="video.thumbnailUrl"
|
||||
:alt="video.comment ?? undefined"
|
||||
/>
|
||||
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.videoRoot">
|
||||
<video
|
||||
v-else
|
||||
ref="videoEl"
|
||||
:class="$style.video"
|
||||
:poster="video.thumbnailUrl ?? undefined"
|
||||
:title="video.comment ?? undefined"
|
||||
:alt="video.comment"
|
||||
preload="metadata"
|
||||
controls
|
||||
@keydown.prevent
|
||||
>
|
||||
<source :src="video.url">
|
||||
</video>
|
||||
<div :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="video.comment" :class="$style.indicator">ALT</div>
|
||||
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.videoRoot">
|
||||
<video
|
||||
ref="videoEl"
|
||||
:class="$style.video"
|
||||
:poster="video.thumbnailUrl ?? undefined"
|
||||
:title="video.comment ?? undefined"
|
||||
:alt="video.comment"
|
||||
preload="metadata"
|
||||
playsinline
|
||||
@keydown.prevent
|
||||
@click.self="togglePlayPause"
|
||||
>
|
||||
<source :src="video.url">
|
||||
</video>
|
||||
<button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ti ti-player-play-filled"></i></button>
|
||||
<div v-else-if="!isActuallyPlaying" :class="$style.videoLoading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
<i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i>
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="video.comment" :class="$style.indicator">ALT</div>
|
||||
<div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
|
||||
</div>
|
||||
<div :class="$style.videoControls" @click.self="togglePlayPause">
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
|
||||
<i v-else class="ti ti-player-play-filled"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleFullscreen">
|
||||
<i v-if="isFullscreen" class="ti ti-arrows-minimize"></i>
|
||||
<i v-else class="ti ti-arrows-maximize"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsVolume]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:sliderBgWhite="true"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
</div>
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:sliderBgWhite="true"
|
||||
:class="$style.seekbarRoot"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
|
||||
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
video: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent): void;
|
||||
}>();
|
||||
const keymap = {
|
||||
'up': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
'down': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'left': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'right': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||
}
|
||||
},
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
if (!playerEl.value) return false;
|
||||
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref(shouldHideFileByDefault(props.video));
|
||||
@@ -77,17 +187,365 @@ async function reveal() {
|
||||
hide.value = false;
|
||||
}
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
os.popupMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
hide.value = true;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (iAmModerator) {
|
||||
menu.push({
|
||||
text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: () => toggleSensitive(props.video),
|
||||
});
|
||||
}
|
||||
|
||||
const details: MenuItem[] = [];
|
||||
if ($i?.id === props.video.userId) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.video.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.moderation,
|
||||
icon: 'ti ti-photo-exclamation',
|
||||
to: `/admin/file/${props.video.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
menu.push({ type: 'divider' }, ...details);
|
||||
}
|
||||
|
||||
if (prefer.s.devMode) {
|
||||
menu.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(props.video.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
|
||||
async function toggleSensitive(file: Misskey.entities.DriveFile) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('drive/files/update', {
|
||||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive,
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Video State
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const playerEl = useTemplateRef('playerEl');
|
||||
const isHoverring = ref(false);
|
||||
const controlsShowing = computed(() => {
|
||||
if (!oncePlayed.value) return true;
|
||||
if (isHoverring.value) return true;
|
||||
if (menuShowing.value) return true;
|
||||
return false;
|
||||
});
|
||||
const isFullscreen = ref(false);
|
||||
let controlStateTimer: number | null = null;
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (!videoEl.value) return;
|
||||
videoEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (!videoEl.value) return 0;
|
||||
return bufferedEnd.value / videoEl.value.duration;
|
||||
});
|
||||
|
||||
// MediaControl Events
|
||||
function onMouseOver() {
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
isHoverring.value = true;
|
||||
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseMove() {
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
isHoverring.value = true;
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
}
|
||||
controlStateTimer = window.setTimeout(() => {
|
||||
isHoverring.value = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value || !videoEl.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.value.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (playerEl.value == null || videoEl.value == null) return;
|
||||
if (isFullscreen.value) {
|
||||
exitFullscreen({
|
||||
videoEl: videoEl.value,
|
||||
});
|
||||
isFullscreen.value = false;
|
||||
} else {
|
||||
requestFullscreen({
|
||||
videoEl: videoEl.value,
|
||||
playerEl: playerEl.value,
|
||||
options: {
|
||||
navigationUI: 'hide',
|
||||
},
|
||||
});
|
||||
isFullscreen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (videoEl.value) {
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.value.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopVideoElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
if (onceInit) return;
|
||||
onceInit = true;
|
||||
|
||||
stopVideoElWatch = watch(videoEl, () => {
|
||||
if (videoEl.value) {
|
||||
isReady.value = true;
|
||||
|
||||
function updateMediaTick() {
|
||||
if (videoEl.value) {
|
||||
try {
|
||||
bufferedEnd.value = videoEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||
|
||||
if (videoEl.value.loop !== loop.value) {
|
||||
loop.value = videoEl.value.loop;
|
||||
}
|
||||
}
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.value.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
});
|
||||
|
||||
videoEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
videoEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
videoEl.value.addEventListener('durationchange', () => {
|
||||
if (videoEl.value) {
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
videoEl.value.volume = volume.value;
|
||||
hasAudio(videoEl.value).then(had => {
|
||||
if (!had && videoEl.value) {
|
||||
videoEl.value.loop = videoEl.value.muted = true;
|
||||
videoEl.value.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (videoEl.value) videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value) videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value) videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(hide, (to) => {
|
||||
if (videoEl.value && to && isFullscreen.value) {
|
||||
exitFullscreen({
|
||||
videoEl: videoEl.value,
|
||||
});
|
||||
isFullscreen.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isReady.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
elapsedTimeMs.value = 0;
|
||||
durationMs.value = 0;
|
||||
bufferedEnd.value = 0;
|
||||
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore');
|
||||
stopVideoElWatch();
|
||||
onceInit = false;
|
||||
if (mediaTickFrameId) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
if (controlStateTimer) {
|
||||
window.clearTimeout(controlStateTimer);
|
||||
controlStateTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
.videoContainer {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
@@ -95,12 +553,6 @@ function onContextmenu(ev: PointerEvent) {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
@@ -119,6 +571,42 @@ function onContextmenu(ev: PointerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
.indicators {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
pointer-events: none;
|
||||
opacity: .5;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
/* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */
|
||||
background-color: black;
|
||||
border-radius: 6px;
|
||||
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
background-color: var(--MI_THEME-fg);
|
||||
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
font-size: 12px;
|
||||
opacity: .5;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -128,7 +616,7 @@ function onContextmenu(ev: PointerEvent) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 12px 0;
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -152,62 +640,154 @@ function onContextmenu(ev: PointerEvent) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.playIconWrapper {
|
||||
.videoOverlayPlayButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%,-50%);
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity .4s ease-in-out;
|
||||
|
||||
background: var(--MI_THEME-accent);
|
||||
color: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 99rem;
|
||||
|
||||
font-size: 1.1rem;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.videoLoading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
.videoControls {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
grid-template-areas:
|
||||
"left time . volume right"
|
||||
"seekbar seekbar seekbar seekbar seekbar";
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
|
||||
padding: 35px 10px 10px 10px;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75));
|
||||
|
||||
.menu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
border-radius: 9px 0 0 0;
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
text-align: center;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .4s ease-in-out, transform .4s ease-in-out;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
border-radius: 0 0 0 9px;
|
||||
.active {
|
||||
.videoControls {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.videoOverlayPlayButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
opacity: .5;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
transition: background-color .15s ease;
|
||||
font-size: 1.05rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
grid-area: time;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
grid-area: volume;
|
||||
|
||||
.volumeSeekbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.seekbarRoot {
|
||||
grid-area: seekbar;
|
||||
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
|
||||
margin: -10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.videoControls {
|
||||
grid-template-areas: "left seekbar time volume right";
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
.volumeSeekbar {
|
||||
max-width: 90px;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.videoControls {
|
||||
grid-template-areas:
|
||||
"left . right"
|
||||
"seekbar seekbar seekbar";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const videoEl = inject(DI.mkImageGalleryItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
|
||||
return bufferedEnd.value / videoEl.value.duration;
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.value?.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.value?.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.value?.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
|
||||
function init() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
isReady.value = true;
|
||||
abortController = new AbortController();
|
||||
|
||||
function updateMediaTick() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
try {
|
||||
bufferedEnd.value = videoEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||
|
||||
if (videoEl.value.loop !== loop.value) {
|
||||
loop.value = videoEl.value.loop;
|
||||
}
|
||||
|
||||
if (videoEl.value.paused !== !isPlaying.value) {
|
||||
isPlaying.value = !videoEl.value.paused;
|
||||
}
|
||||
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.value.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
videoEl.value.addEventListener('durationchange', () => {
|
||||
durationMs.value = videoEl.value!.duration * 1000;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.volume = volume.value;
|
||||
hasAudio(videoEl.value).then(had => {
|
||||
if (!had) {
|
||||
videoEl.value!.loop = videoEl.value!.muted = true;
|
||||
videoEl.value!.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(videoEl, () => {
|
||||
if (abortController != null) {
|
||||
abortController.abort();
|
||||
}
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -19,5 +19,4 @@ export const DI = {
|
||||
inModal: Symbol() as InjectionKey<boolean>,
|
||||
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
|
||||
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
|
||||
mkImageGalleryItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function makeDoubleTapDetector(onDoubletap: (event: TouchEvent) => void) {
|
||||
const positionThreshold = 10; // px
|
||||
const durationThreshold = 300; // ms
|
||||
|
||||
let lastTapTime = 0;
|
||||
let lastTapPosition = { x: 0, y: 0 };
|
||||
|
||||
function onTouchstart(ev: TouchEvent) {
|
||||
if (ev.touches.length !== 1) return;
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
const tapLength = currentTime - lastTapTime;
|
||||
const positionDelta = Math.max(
|
||||
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
|
||||
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
|
||||
);
|
||||
|
||||
if (tapLength < durationThreshold && tapLength > 0 && positionDelta < positionThreshold) { // ダブルタップ
|
||||
onDoubletap(ev);
|
||||
lastTapTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
lastTapTime = currentTime;
|
||||
lastTapPosition = {
|
||||
x: ev.touches[0].clientX,
|
||||
y: ev.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
|
||||
function onTouchmove(ev: TouchEvent) {
|
||||
const positionDelta = Math.max(
|
||||
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
|
||||
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
|
||||
);
|
||||
if (positionDelta > positionThreshold) {
|
||||
lastTapTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lastTapTime = 0;
|
||||
lastTapPosition = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
onTouchstart,
|
||||
onTouchmove,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -97,7 +97,6 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
|
||||
globalEvents.emit('driveFilesDeleted', [file]);
|
||||
}
|
||||
|
||||
/** 自分のドライブファイルを操作する際のメニュー */
|
||||
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
|
||||
const _isImage = file.type.startsWith('image/');
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import * as os from '@/os.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
/** 添付ファイルなど、公開ファイル用のメニュー */
|
||||
export function getFileMenu(file: Misskey.entities.DriveFile, onHideStateUpdated?: (newState: boolean) => void): MenuItem[] {
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (onHideStateUpdated != null) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
onHideStateUpdated(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
menuItems.push({
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('drive/files/update', {
|
||||
fileId: file.id,
|
||||
isSensitive: !file.isSensitive,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const details: MenuItem[] = [];
|
||||
if ($i?.id === file.userId) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${file.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (iAmModerator) {
|
||||
details.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.moderation,
|
||||
icon: 'ti ti-photo-exclamation',
|
||||
to: `/admin/file/${file.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (details.length > 0) {
|
||||
menuItems.push({ type: 'divider' }, ...details);
|
||||
}
|
||||
|
||||
if (prefer.s.devMode) {
|
||||
menuItems.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-hash',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
copyToClipboard(file.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
|
||||
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
|
||||
const actions = parseKeymap(keymap);
|
||||
return (ev: KeyboardEvent) => {
|
||||
if ('pswp' in window && window.pswp != null) return;
|
||||
if (window.document.activeElement != null) {
|
||||
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
|
||||
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;
|
||||
|
||||
@@ -8,8 +8,8 @@ import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile, ignoreDataSaver = false): boolean {
|
||||
if (prefer.s.nsfw === 'force' || (!ignoreDataSaver && prefer.s.dataSaver.media)) {
|
||||
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
|
||||
if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
// locator helper
|
||||
locateMkInput, locateMkSwitch, locateMkTextarea,
|
||||
// utils
|
||||
registerUser, resetState, visitHome,
|
||||
registerUser, resetState, visitHome, closeUserSetupDialog, postNote,
|
||||
// page utils
|
||||
waitApiResponse, signIn,
|
||||
} from './utils.js';
|
||||
@@ -194,17 +194,11 @@ test.describe('After user setup', () => {
|
||||
await signIn(page, 'alice', 'alice1234');
|
||||
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 });
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
await closeUserSetupDialog(page);
|
||||
});
|
||||
|
||||
test('note', async ({ page }) => {
|
||||
await page.getByTestId('open-post-form').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('open-post-form').click();
|
||||
await page.getByTestId('post-form-text').fill('Hello, Misskey!');
|
||||
await page.getByTestId('post-form-submit').click();
|
||||
|
||||
await page.getByText('Hello, Misskey!').waitFor({ timeout: 15000 });
|
||||
await postNote(page, 'Hello, Misskey!');
|
||||
});
|
||||
|
||||
test('open note form with hotkey', async ({ page }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
// utils
|
||||
resetState, registerUser,
|
||||
// page utils
|
||||
signIn,
|
||||
signIn, closeUserSetupDialogIfVisible,
|
||||
} from './utils.js';
|
||||
|
||||
test.describe('Router transition', () => {
|
||||
@@ -26,10 +26,7 @@ test.describe('Router transition', () => {
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする。少し待つ
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
if (await page.getByTestId('user-setup-dialog').isVisible()) {
|
||||
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click();
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
}
|
||||
await closeUserSetupDialogIfVisible(page);
|
||||
});
|
||||
|
||||
test.describe('Redirect', () => {
|
||||
|
||||
135
packages/frontend/test/e2e/shared.ts
Normal file
135
packages/frontend/test/e2e/shared.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from 'playwright';
|
||||
|
||||
export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked';
|
||||
export const DEFAULT_INVITATION_CODE = 'test-invitation-code';
|
||||
|
||||
export interface RegisteredUser {
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function assertOk(status: number, route: string): void {
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(`${route} failed: status=${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
|
||||
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
assertOk(response.status, `/api/${endpoint}`);
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function resetState(baseUrl: string): Promise<void> {
|
||||
await api(baseUrl, 'reset-db', {});
|
||||
}
|
||||
|
||||
export async function registerUser(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin = false,
|
||||
): Promise<RegisteredUser> {
|
||||
const route = isAdmin ? 'admin/accounts/create' : 'signup';
|
||||
const result = await api(baseUrl, route, {
|
||||
username,
|
||||
password,
|
||||
...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}),
|
||||
});
|
||||
return result as RegisteredUser;
|
||||
}
|
||||
|
||||
export function locateMkInput(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] input`);
|
||||
}
|
||||
|
||||
export function locateMkTextarea(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] textarea`);
|
||||
}
|
||||
|
||||
export function locateMkSwitch(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`);
|
||||
}
|
||||
|
||||
export async function visitHome(page: Page, baseUrl: string): Promise<void> {
|
||||
await page.goto(`${baseUrl}/`);
|
||||
await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function waitApiResponse(page: Page, path: string, timeout = 30_000): Promise<void> {
|
||||
await page.waitForResponse((response) => {
|
||||
return response.url().endsWith(path) && response.request().method() === 'POST';
|
||||
}, { timeout });
|
||||
}
|
||||
|
||||
export async function signIn(page: Page, baseUrl: string, username: string, password: string): Promise<void> {
|
||||
await visitHome(page, baseUrl);
|
||||
await page.getByTestId('signin').click();
|
||||
await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await locateMkInput(page, 'signin-username').fill(username);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await locateMkInput(page, 'signin-password').fill(password);
|
||||
const signinResponse = waitApiResponse(page, '/api/signin-flow');
|
||||
await page.keyboard.press('Enter');
|
||||
await signinResponse;
|
||||
}
|
||||
|
||||
export async function acceptSignupRules(page: Page): Promise<void> {
|
||||
await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' });
|
||||
await locateMkSwitch(page, 'signup-rules-notes-agree').click();
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
await page.getByTestId('signup-rules-continue').click();
|
||||
}
|
||||
|
||||
export async function signupThroughUi(
|
||||
page: Page,
|
||||
options: {
|
||||
username: string;
|
||||
password: string;
|
||||
invitationCode?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.getByTestId('signup').click();
|
||||
await acceptSignupRules(page);
|
||||
|
||||
await locateMkInput(page, 'signup-username').fill(options.username);
|
||||
await locateMkInput(page, 'signup-password').fill(options.password);
|
||||
await locateMkInput(page, 'signup-password-retype').fill(options.password);
|
||||
await locateMkInput(page, 'signup-invitation-code').fill(options.invitationCode ?? DEFAULT_INVITATION_CODE);
|
||||
|
||||
const signupResponse = waitApiResponse(page, '/api/signup');
|
||||
await page.getByTestId('signup-submit').click();
|
||||
await signupResponse;
|
||||
}
|
||||
|
||||
export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout });
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
}
|
||||
|
||||
export async function closeUserSetupDialogIfVisible(page: Page): Promise<void> {
|
||||
if (await page.getByTestId('user-setup-dialog').isVisible()) {
|
||||
await closeUserSetupDialog(page);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postNote(page: Page, noteText: string, timeout = 15_000): Promise<void> {
|
||||
await page.getByTestId('open-post-form').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('open-post-form').click();
|
||||
await page.getByTestId('post-form-text').fill(noteText);
|
||||
await page.getByTestId('post-form-submit').click();
|
||||
await page.getByText(noteText).waitFor({ timeout });
|
||||
}
|
||||
@@ -3,92 +3,50 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Locator, Page } from 'playwright';
|
||||
import type { Page } from 'playwright';
|
||||
import {
|
||||
registerUser as registerUserWithBaseUrl,
|
||||
resetState as resetStateWithBaseUrl,
|
||||
signIn as signInWithBaseUrl,
|
||||
visitHome as visitHomeWithBaseUrl,
|
||||
} from './shared.js';
|
||||
export type { RegisteredUser } from './shared.js';
|
||||
export {
|
||||
ADMIN_SETUP_PASSWORD,
|
||||
DEFAULT_INVITATION_CODE,
|
||||
acceptSignupRules,
|
||||
assertOk,
|
||||
closeUserSetupDialog,
|
||||
closeUserSetupDialogIfVisible,
|
||||
locateMkInput,
|
||||
locateMkSwitch,
|
||||
locateMkTextarea,
|
||||
postNote,
|
||||
waitApiResponse,
|
||||
} from './shared.js';
|
||||
|
||||
export const BASE_URL = 'http://localhost:61812';
|
||||
export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked';
|
||||
|
||||
export interface RegisteredUser {
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
//#region Misc
|
||||
export function assertOk(status: number, route: string): void {
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(`${route} failed: status=${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetState(): Promise<void> {
|
||||
const response = await fetch(`${BASE_URL}/api/reset-db`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
assertOk(response.status, '/api/reset-db');
|
||||
await resetStateWithBaseUrl(BASE_URL);
|
||||
}
|
||||
|
||||
export async function registerUser(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin = false,
|
||||
): Promise<RegisteredUser> {
|
||||
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
|
||||
const response = await fetch(`${BASE_URL}${route}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
assertOk(response.status, route);
|
||||
return await response.json() as RegisteredUser;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Locator Helpers
|
||||
export function locateMkInput(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] input`);
|
||||
}
|
||||
|
||||
export function locateMkTextarea(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] textarea`);
|
||||
}
|
||||
|
||||
export function locateMkSwitch(page: Page, testId: string): Locator {
|
||||
return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`);
|
||||
): ReturnType<typeof registerUserWithBaseUrl> {
|
||||
return registerUserWithBaseUrl(BASE_URL, username, password, isAdmin);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Page Helpers
|
||||
export async function visitHome(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/`);
|
||||
await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function waitApiResponse(page: Page, path: string): Promise<void> {
|
||||
await page.waitForResponse((response) => {
|
||||
return response.url().endsWith(path) && response.request().method() === 'POST';
|
||||
}, { timeout: 30_000 });
|
||||
await visitHomeWithBaseUrl(page, BASE_URL);
|
||||
}
|
||||
|
||||
export async function signIn(page: Page, username: string, password: string): Promise<void> {
|
||||
await visitHome(page);
|
||||
await page.getByTestId('signin').click();
|
||||
await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await locateMkInput(page, 'signin-username').fill(username);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await locateMkInput(page, 'signin-password').fill(password);
|
||||
const signinResponse = waitApiResponse(page, '/api/signin-flow');
|
||||
await page.keyboard.press('Enter');
|
||||
await signinResponse;
|
||||
await signInWithBaseUrl(page, BASE_URL, username, password);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -220,6 +220,9 @@ export function getConfig(): UserConfig {
|
||||
groups: [{
|
||||
name: 'vue',
|
||||
test: /node_modules[\\/]vue/,
|
||||
}, {
|
||||
name: 'photoswipe',
|
||||
test: /node_modules[\\/]photoswipe/,
|
||||
}, {
|
||||
// split i18n related module to distinct module
|
||||
name: 'i18n',
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -646,6 +646,9 @@ importers:
|
||||
misskey-reversi:
|
||||
specifier: workspace:*
|
||||
version: link:../misskey-reversi
|
||||
photoswipe:
|
||||
specifier: 5.4.4
|
||||
version: 5.4.4
|
||||
punycode.js:
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1
|
||||
@@ -7234,6 +7237,10 @@ packages:
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
photoswipe@5.4.4:
|
||||
resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
|
||||
engines: {node: '>= 0.12.0'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -15630,6 +15637,8 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
photoswipe@5.4.4: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
308
scripts/dev.mjs
308
scripts/dev.mjs
@@ -10,264 +10,104 @@ import { execa } from 'execa';
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/** @type {Set<import('execa').ResultPromise>} */
|
||||
const childProcesses = new Set();
|
||||
/** @type {Set<import('execa').ResultPromise>} */
|
||||
const persistentChildProcesses = new Set();
|
||||
let shuttingDown = false;
|
||||
let persistentChildProcessesStarted = false;
|
||||
let persistentChildProcessFailed = false;
|
||||
/** @type {Promise<void> | null} */
|
||||
let shutdownPromise = null;
|
||||
|
||||
/**
|
||||
* 開発用コマンドを起動し、終了時にまとめて停止できるよう追跡する。
|
||||
* Windows では Ctrl+C の配信先を分離し、出力を親コンソールへ中継する。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {import('execa').ResultPromise} 起動した子プロセス。
|
||||
*/
|
||||
function spawnChildProcess(command, args, options) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const pnpmPath = _dirname + '/../node_modules/pnpm/bin/pnpm.mjs';
|
||||
const windowsCommand = [process.execPath, pnpmPath, ...args]
|
||||
.map(argument => `"${argument}"`)
|
||||
.join(' ');
|
||||
const executable = isWindows && command === 'pnpm' ? process.env.ComSpec ?? 'cmd.exe' : command;
|
||||
const executableArgs = isWindows && command === 'pnpm'
|
||||
? ['/d', '/s', '/c', `start "" /b /wait ${windowsCommand}`]
|
||||
: args;
|
||||
const childProcess = execa(executable, executableArgs, isWindows ? {
|
||||
...options,
|
||||
// `start /b` keeps the process in the current console without forwarding
|
||||
// Ctrl+C, allowing only this supervisor to coordinate the shutdown.
|
||||
windowsVerbatimArguments: true,
|
||||
windowsHide: false,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
buffer: false,
|
||||
} : options);
|
||||
|
||||
if (isWindows) {
|
||||
if (options.stdout != null) childProcess.stdout?.pipe(options.stdout, { end: false });
|
||||
if (options.stderr != null) childProcess.stderr?.pipe(options.stderr, { end: false });
|
||||
}
|
||||
|
||||
childProcesses.add(childProcess);
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子プロセスの終了を待機し、追跡対象から取り除く。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {Promise<import('execa').Result>} 子プロセスの実行結果。
|
||||
*/
|
||||
async function runChildProcess(command, args, options) {
|
||||
const childProcess = spawnChildProcess(command, args, options);
|
||||
|
||||
try {
|
||||
return await childProcess;
|
||||
} finally {
|
||||
childProcesses.delete(childProcess);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常駐する子プロセスがすべて終了していれば、終了結果を引き継いで親も終了する。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function shutdownIfAllPersistentChildProcessesStopped() {
|
||||
if (shuttingDown || !persistentChildProcessesStarted || persistentChildProcesses.size > 0) return;
|
||||
|
||||
void shutdown(persistentChildProcessFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 常駐する子プロセスを起動し、終了時の追跡解除・エラー出力・親の終了判定を設定する。
|
||||
*
|
||||
* @param {string} command - 実行するコマンド。
|
||||
* @param {string[]} args - コマンドへ渡す引数。
|
||||
* @param {import('execa').Options} options - execa の起動オプション。
|
||||
* @returns {void}
|
||||
*/
|
||||
function startChildProcess(command, args, options) {
|
||||
const childProcess = spawnChildProcess(command, args, options);
|
||||
persistentChildProcesses.add(childProcess);
|
||||
|
||||
void childProcess.then(() => {
|
||||
childProcesses.delete(childProcess);
|
||||
persistentChildProcesses.delete(childProcess);
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
}, error => {
|
||||
childProcesses.delete(childProcess);
|
||||
persistentChildProcesses.delete(childProcess);
|
||||
if (!shuttingDown) {
|
||||
persistentChildProcessFailed = true;
|
||||
console.error(error);
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 子プロセスとその配下のプロセスを停止し、終了を待機する。
|
||||
*
|
||||
* @param {import('execa').ResultPromise} childProcess - 停止する子プロセス。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function stopChildProcess(childProcess) {
|
||||
if (process.platform === 'win32' && childProcess.pid != null) {
|
||||
const result = await execa('taskkill', ['/pid', childProcess.pid.toString(), '/t', '/f'], {
|
||||
reject: false,
|
||||
});
|
||||
if (result.failed) childProcess.kill();
|
||||
} else {
|
||||
childProcess.kill();
|
||||
}
|
||||
|
||||
await childProcess.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 追跡中の子プロセスを一度だけ停止して、指定した終了コードで終了する。
|
||||
*
|
||||
* @param {number} exitCode - 親プロセスに返す終了コード。
|
||||
* @returns {Promise<void>} 実行中または完了した停止処理。
|
||||
*/
|
||||
function shutdown(exitCode) {
|
||||
if (shutdownPromise != null) return shutdownPromise;
|
||||
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
await Promise.allSettled([...childProcesses].map(stopChildProcess));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown(0);
|
||||
await execa('pnpm', ['clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown(0);
|
||||
// アセットのビルドで依存しているので一番最初に必要
|
||||
await execa('pnpm', ['--filter', 'i18n', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
try {
|
||||
await runChildProcess('pnpm', ['clean'], {
|
||||
await Promise.all([
|
||||
execa('pnpm', ['build-pre'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
// アセットのビルドで依存しているので一番最初に必要
|
||||
await runChildProcess('pnpm', ['--filter', 'i18n', 'build'], {
|
||||
}),
|
||||
execa('pnpm', ['build-assets'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
runChildProcess('pnpm', ['build-pre'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['build-assets'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
|
||||
runChildProcess('pnpm', ['--filter', 'icons-subsetter', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
runChildProcess('pnpm', ['--filter', 'misskey-js', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}),
|
||||
]);
|
||||
|
||||
startChildProcess('pnpm', ['build-pre', '--watch'], {
|
||||
}),
|
||||
execa('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['build-assets', '--watch'], {
|
||||
}),
|
||||
// icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする
|
||||
execa('pnpm', ['--filter', 'icons-subsetter', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'backend', 'dev'], {
|
||||
}),
|
||||
execa('pnpm', ['--filter', 'misskey-js', 'build'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'frontend', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['build-pre', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'frontend-embed', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['build-assets', '--watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'sw', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['--filter', 'backend', 'dev'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['--filter', 'frontend', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['--filter', 'frontend-embed', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['--filter', 'sw', 'watch'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
startChildProcess('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
execa('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
persistentChildProcessesStarted = true;
|
||||
shutdownIfAllPersistentChildProcessesStopped();
|
||||
} catch (error) {
|
||||
if (!shuttingDown) {
|
||||
console.error(error);
|
||||
await shutdown(1);
|
||||
}
|
||||
}
|
||||
execa('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
execa('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], {
|
||||
cwd: _dirname + '/../',
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user