Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/min.node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.22.2
24.17.0
3 changes: 0 additions & 3 deletions .github/workflows/test-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@ jobs:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/test-federation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ jobs:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Install FFmpeg
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
with:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ ENV PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg tini curl libjemalloc-dev libjemalloc2 \
tini curl libjemalloc-dev libjemalloc2 \
&& ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \
&& groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
Expand Down
6 changes: 2 additions & 4 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"type": "module",
"engines": {
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
"node": "^24.17.0 || ^26.4.0"
},
"scripts": {
"start": "pnpm compile-config && node ./built/entry.js",
Expand Down Expand Up @@ -92,7 +92,6 @@
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
"chokidar": "5.0.0",
"color-convert": "3.1.3",
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
Expand All @@ -101,7 +100,6 @@
"fastify-raw-body": "5.0.0",
"feed": "6.0.0",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
"got": "15.1.0",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
Expand All @@ -121,6 +119,7 @@
"ms": "3.0.0-canary.202508261828",
"nanoid": "6.0.0",
"nested-property": "4.0.0",
"node-av": "6.1.1",
"node-fetch": "3.3.2",
"node-html-parser": "9.0.0",
"nodemailer": "9.0.3",
Expand Down Expand Up @@ -163,7 +162,6 @@
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
Expand Down
1 change: 1 addition & 0 deletions packages/backend/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default defineConfig((args) => {
'sharp',
'jsdom',
're2',
'node-av',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
'ipaddr.js',
'file-type',
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
Expand Down
234 changes: 104 additions & 130 deletions packages/backend/src/core/FileInfoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,22 @@

import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { join } from 'node:path';
import * as stream from 'node:stream/promises';
import { Injectable } from '@nestjs/common';
import { FSWatcher } from 'chokidar';
import * as fileType from 'file-type';
import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { Decoder, Demuxer, FilterAPI, Scaler, probe } from 'node-av/api';
import { AV_PICTURE_TYPE_I } from 'node-av/constants';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import type { Prediction } from '@/core/AiService.js';
import '@/misc/node-av-log.js';

export type FileInfo = {
size: number;
Expand Down Expand Up @@ -49,6 +48,26 @@ const TYPE_SVG = {
ext: 'svg',
};

/** 映像トラックの有無を調べる probe の制限時間 */
const PROBE_TIMEOUT_MS = 10 * 1000;

/**
* センシティブ判定用のフレーム抽出全体の制限時間。
* 長尺・高解像度の動画でデコードが延々と続くのを防ぐ。
* 時間切れになった場合は、そこまでに集まったフレームだけで判定する。
*/
const FRAME_EXTRACTION_TIMEOUT_MS = 60 * 1000;

/** 判定対象から除外する暗部の割合 (これ以上暗いフレームは誤検知を招くため使わない) */
const MAX_BLACK_PERCENTAGE = 50;

/** 判定サービス側のデコーダが内部で使う画像サイズ */
const DETECTION_IMAGE_SIZE = 299;

function isAbortError(err: unknown): boolean {
return err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
}

@Injectable()
export class FileInfoService {
private logger: Logger;
Expand Down Expand Up @@ -206,77 +225,16 @@ export class FileInfoService {
}

if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
const [outDir, disposeOutDir] = await createTempDir();
try {
const command = FFmpeg()
.input(source)
.inputOptions([
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
])
.noAudio()
.videoFilters([
{
filter: 'select', // フレームのフィルタリング
options: {
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい)
},
},
{
filter: 'blackframe', // 暗いフレームの検出
options: {
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
},
},
{
filter: 'metadata',
options: {
mode: 'select', // フレーム選択モード
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
value: '50',
function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
},
},
{
filter: 'scale',
options: {
w: 299,
h: 299,
},
},
])
.format('image2')
.output(join(outDir, '%d.png'))
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
// 判定対象フレームを選定して正規化済みバッファとして集め、外部サービスへまとめて送る。
const frameBuffers: Buffer[] = [];
let frameIndex = 0;
let targetIndex = 0;
let nextIndex = 1;
for await (const path of this.asyncIterateFrames(outDir, command)) {
try {
const index = frameIndex++;
if (index !== targetIndex) {
continue;
}
targetIndex = nextIndex;
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
frameBuffers.push(await fs.promises.readFile(path));
} finally {
fs.promises.unlink(path);
}
}
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
// 1 件以上判定できたときのみ集約する(失敗時は非センシティブ扱い: misskey-dev/misskey#16804)。
if (results.length > 0) {
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
}
} finally {
disposeOutDir();
// 判定対象フレームを選定して正規化済みバッファとして集め、外部サービスへまとめて送る。
const frameBuffers = await this.extractFramesForDetection(source);
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
// 1 件以上判定できたときのみ集約する(失敗時は非センシティブ扱い: misskey-dev/misskey#16804)。
if (results.length > 0) {
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
}
} else if (isMimeImage(mime, 'sharp-convertible-image-with-bmp')) {
/*
Expand All @@ -300,47 +258,70 @@ export class FileInfoService {
return [sensitive, porn];
}

private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
const watcher = new FSWatcher({
cwd,
});
let finished = false;
command.once('end', () => {
finished = true;
watcher.close();
});
command.run();
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
const current = `${i}.png`;
const next = `${i + 1}.png`;
const framePath = join(cwd, current);
if (await this.exists(join(cwd, next))) {
yield framePath;
} else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
watcher.add(next);
await new Promise<void>((resolve, reject) => {
watcher.on('add', function onAdd(path) {
if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている
watcher.unwatch(current);
watcher.off('add', onAdd);
resolve();
}
});
command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている
command.once('error', reject);
});
yield framePath;
} else if (await this.exists(framePath)) {
yield framePath;
} else {
return;
/**
* センシティブ判定に掛けるフレームを、正規化済みの PNG バッファとして抽出する。
*
* 制限時間を超えた場合はそこまでに集まった分を返す。フレームが 1 枚も取れなくても
* 呼び出し元の「判定成功 0 件なら非センシティブ」の扱いに落ちるため、例外にはしない。
*
* @param source ファイルパス
* @returns 299x299 に正規化された PNG バッファの配列
*/
@bindThis
private async extractFramesForDetection(source: string): Promise<Buffer[]> {
const signal = AbortSignal.timeout(FRAME_EXTRACTION_TIMEOUT_MS);
const frameBuffers: Buffer[] = [];

try {
await using demuxer = await Demuxer.open(source, { signal });

const videoStream = demuxer.video();
if (videoStream == null) return frameBuffers;

using decoder = await Decoder.create(videoStream, {
signal,
options: {
'skip_frame': 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
'lowres': '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
},
});
// 暗さに関わらず全てのフレームで測定値を取り、lavfi.blackframe.pblack として受け取る
using filter = FilterAPI.create('blackframe=amount=0', { signal });
using scaler = new Scaler();

let frameIndex = 0;
let targetIndex = 0;
let nextIndex = 1;

for await (using frame of decoder.frames(demuxer.packets(videoStream.index))) {
// I-Frame のみを対象にする(VP9 とかはデコードしてみないとわからないっぽい)
if (frame == null || frame.pictType !== AV_PICTURE_TYPE_I) continue;

for await (using measured of filter.frames(frame)) {
if (measured == null) continue;

// フレームにおける暗部の百分率。取得できなかったフレームは判定に使わない
// (Number(null) が 0 になり、真っ暗なフレームを通してしまうため明示的に弾く)
const pblack = measured.getMetadata().get('lavfi.blackframe.pblack');
if (pblack == null || !(Number(pblack) < MAX_BLACK_PERCENTAGE)) continue;

const index = frameIndex++;
if (index !== targetIndex) continue;
targetIndex = nextIndex;
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

frameBuffers.push(await scaler.toPng(measured, {
resize: { width: DETECTION_IMAGE_SIZE, height: DETECTION_IMAGE_SIZE },
}));
}
}
} catch (err) {
// 時間切れは想定内。集まった分だけで判定を続ける
if (!isAbortError(err)) throw err;
this.logger.warn(`Frame extraction timed out after ${FRAME_EXTRACTION_TIMEOUT_MS}ms, using ${frameBuffers.length} frame(s). File path: ${source}`);
}
}

@bindThis
private exists(path: string): Promise<boolean> {
return fs.promises.access(path).then(() => true, () => false);
return frameBuffers;
}

@bindThis
Expand All @@ -364,24 +345,17 @@ export class FileInfoService {
* @returns ビデオトラックがあるかどうか(エラー発生時は常に`true`を返す)
*/
@bindThis
private hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
const sublogger = this.logger.createSubLogger('ffprobe');
private async hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
const sublogger = this.logger.createSubLogger('probe');
sublogger.info(`Checking the video file. File path: ${path}`);
return new Promise((resolve) => {
try {
FFmpeg.ffprobe(path, (err, metadata) => {
if (err) {
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
resolve(true);
return;
}
resolve(metadata.streams.some((stream) => stream.codec_type === 'video'));
});
} catch (err) {
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
resolve(true);
}
});
try {
const result = await probe(path, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
return result.streams.some((stream) => stream.type === 'video');
} catch (err) {
const reason = isAbortError(err) ? `Timed out after ${PROBE_TIMEOUT_MS}ms` : 'Could not check the video file';
sublogger.warn(`${reason}. Returns true. File path: ${path}`, err as Error);
return true;
}
}

/**
Expand Down
Loading
Loading