MicProtocol / apps /api /dist /queue /clip-scoring.processor.js
Benaah's picture
Upload folder using huggingface_hub (part 2)
9f29339 verified
Raw History Blame Contribute Delete
15.6 kB
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var ClipScoringProcessor_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ClipScoringProcessor = void 0;
const bullmq_1 = require("@nestjs/bullmq");
const common_1 = require("@nestjs/common");
const child_process_1 = require("child_process");
const util_1 = require("util");
const path = __importStar(require("path"));
const fs = __importStar(require("fs/promises"));
const os = __importStar(require("os"));
const prisma_service_1 = require("../prisma/prisma.service");
const r2_storage_service_1 = require("../common/storage/r2-storage.service");
const redis_constants_1 = require("../common/redis.constants");
const safe_fetch_1 = require("../common/safe-fetch");
const execAsync = (0, util_1.promisify)(child_process_1.execFile);
const RELEASE_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const EXTEND_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`;
let ClipScoringProcessor = ClipScoringProcessor_1 = class ClipScoringProcessor extends bullmq_1.WorkerHost {
prisma;
r2Storage;
logger = new common_1.Logger(ClipScoringProcessor_1.name);
redis;
releaseLockSha = null;
extendLockSha = null;
constructor(prisma, r2Storage, redis) {
super();
this.prisma = prisma;
this.r2Storage = r2Storage;
this.redis = redis;
}
async onModuleInit() {
try {
this.releaseLockSha = await this.redis.scriptLoad(RELEASE_LOCK_SCRIPT);
this.extendLockSha = await this.redis.scriptLoad(EXTEND_LOCK_SCRIPT);
this.logger.log("Lua scripts compiled for clip-scoring locks");
}
catch (err) {
this.logger.warn("Failed to pre-compile Lua scripts, will use EVAL fallback");
}
}
async process(job) {
const { clipId, episodeId, videoUrl, transcriptText } = job.data;
this.logger.log(`Processing clip scoring for ${clipId}`);
const lockKey = `lock:clip:${clipId}`;
const lockValue = `worker:${job.id}:${Date.now()}`;
const lockTTL = 300000;
const acquired = await this.redis.set(lockKey, lockValue, {
NX: true,
PX: lockTTL,
});
if (!acquired) {
await job.log(`Skipping job ${job.id}: clip ${clipId} is already being scored`);
return { clipId, status: "skipped", reason: "locked" };
}
const heartbeat = setInterval(async () => {
try {
const extended = await this.evalLua(this.extendLockSha, EXTEND_LOCK_SCRIPT, [lockKey], [lockValue, String(lockTTL)]);
if (!extended) {
this.logger.warn(`Lock lost for clip ${clipId}, aborting heartbeat`);
clearInterval(heartbeat);
}
}
catch (err) {
this.logger.error(`Heartbeat extend failed: ${err}`);
}
}, 60000);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `clip-score-${clipId}-`));
try {
await job.updateProgress(5);
const sourcePath = path.join(tmpDir, "source.mp4");
await this.downloadVideo(videoUrl, sourcePath);
await job.updateProgress(15);
const clipBounds = await this.prisma.clip.findUnique({
where: { id: clipId },
select: { startSeconds: true, endSeconds: true },
});
const clipStart = clipBounds?.startSeconds ?? 0;
const clipEnd = clipBounds?.endSeconds ?? 0;
const clipDuration = Math.max(0, clipEnd - clipStart);
const trimmedPath = path.join(tmpDir, "trimmed.mp4");
await this.trimSource(sourcePath, trimmedPath, clipStart, clipDuration);
await job.updateProgress(25);
const framesDir = path.join(tmpDir, "frames");
await fs.mkdir(framesDir, { recursive: true });
await this.extractFrames(trimmedPath, framesDir);
await job.updateProgress(40);
const audioPath = path.join(tmpDir, "audio.wav");
await this.extractAudio(trimmedPath, audioPath);
await job.updateProgress(50);
const pythonResult = await this.runPythonPipeline(framesDir, audioPath, transcriptText, tmpDir);
await job.updateProgress(85);
if (pythonResult.status === "error") {
throw new Error(`Python scoring failed: ${pythonResult.message}`);
}
await this.prisma.clip.update({
where: { id: clipId },
data: {
visualScore: Math.round(pythonResult.breakdown.visualAesthetic),
audioScore: Math.round(pythonResult.breakdown.audioCompliance),
transcriptScore: Math.round(pythonResult.breakdown.narrativeHook),
scoringMetadata: pythonResult.metadata,
scoredAt: new Date(),
},
});
await job.updateProgress(100);
this.logger.log(`Clip ${clipId} scored: overall=${pythonResult.overallScore}, ` +
`visual=${pythonResult.breakdown.visualAesthetic}, ` +
`audio=${pythonResult.breakdown.audioCompliance}, ` +
`hook=${pythonResult.breakdown.narrativeHook}`);
return {
clipId,
status: "completed",
overallScore: pythonResult.overallScore,
breakdown: pythonResult.breakdown,
};
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.logger.error(`Clip scoring failed for ${clipId}: ${msg}`);
throw error;
}
finally {
clearInterval(heartbeat);
await this.releaseLock(lockKey, lockValue);
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async evalLua(sha, script, keys, args) {
if (sha) {
try {
return await this.redis.evalSha(sha, { keys, arguments: args });
}
catch (err) {
if (err?.message?.includes("NOSCRIPT")) {
return await this.redis.eval(script, { keys, arguments: args });
}
throw err;
}
}
return await this.redis.eval(script, { keys, arguments: args });
}
async releaseLock(lockKey, lockValue) {
try {
await this.evalLua(this.releaseLockSha, RELEASE_LOCK_SCRIPT, [lockKey], [lockValue]);
}
catch (err) {
this.logger.error(`Failed to release lock ${lockKey}: ${err}`);
}
}
async downloadVideo(url, destPath) {
let downloadUrl = url;
if (!url.startsWith("http")) {
downloadUrl = await this.r2Storage.getPresignedUrl(url, 3600);
}
const r2PublicHost = process.env.R2_PUBLIC_HOST || "r2.cloudflarestorage.com";
const allowedHostSuffixes = [
`.${r2PublicHost}`,
".s3.amazonaws.com",
".storage.googleapis.com",
".blob.core.windows.net",
".digitaloceanspaces.com",
];
try {
(0, safe_fetch_1.assertSafeUrl)(downloadUrl, { allowedHostSuffixes });
}
catch (err) {
try {
const u = new URL(downloadUrl);
(0, safe_fetch_1.assertSafeUrl)(downloadUrl, { allowedHosts: [u.hostname] });
}
catch {
throw err;
}
}
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download video: ${response.status} ${response.statusText}`);
}
const MAX_VIDEO_BYTES = 10 * 1024 * 1024 * 1024;
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && contentLength > MAX_VIDEO_BYTES) {
throw new Error(`Video too large (${contentLength} bytes exceeds the ${MAX_VIDEO_BYTES}-byte cap)`);
}
if (!response.body) {
throw new Error("Downloaded video response has no body stream");
}
let totalBytes = 0;
const reader = response.body.getReader();
try {
const fileHandle = await fs.open(destPath, "w");
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
totalBytes += value.byteLength;
if (totalBytes > MAX_VIDEO_BYTES) {
throw new Error(`Video exceeds the ${MAX_VIDEO_BYTES}-byte cap; aborting download`);
}
await fileHandle.write(value);
}
}
finally {
await fileHandle.close().catch(() => undefined);
}
}
finally {
reader.cancel().catch(() => undefined);
}
}
async trimSource(sourcePath, outputPath, startSeconds, durationSeconds) {
const args = [
"-y",
"-ss", String(Math.max(0, startSeconds)),
"-i", sourcePath,
"-t", String(Math.max(0, durationSeconds)),
"-c", "copy",
"-avoid_negative_ts", "make_zero",
outputPath,
];
try {
await execAsync("ffmpeg", args, { timeout: 60000 });
}
catch (err) {
const fallbackArgs = [
"-y",
"-ss", String(Math.max(0, startSeconds)),
"-i", sourcePath,
"-t", String(Math.max(0, durationSeconds)),
"-c:v", "libx264",
"-c:a", "aac",
"-preset", "ultrafast",
outputPath,
];
await execAsync("ffmpeg", fallbackArgs, { timeout: 180000 });
}
}
async extractFrames(sourcePath, framesDir) {
const outPattern = path.join(framesDir, "frame_%04d.jpg");
const args = [
"-y",
"-i", sourcePath,
"-vf", "fps=1,scale=224:224:force_original_aspect_ratio=decrease,pad=224:224:(ow-iw)/2:(oh-ih)/2",
"-q:v", "2",
outPattern,
];
try {
await execAsync("ffmpeg", args, { timeout: 120000 });
}
catch (err) {
const message = err?.message || String(err);
if (message.includes("No such file or directory")) {
throw new Error("FFmpeg not found. Ensure FFmpeg is installed in the worker container.");
}
throw new Error(`FFmpeg frame extraction failed: ${message}`);
}
}
async extractAudio(sourcePath, audioPath) {
const args = [
"-y",
"-i", sourcePath,
"-vn",
"-acodec", "pcm_s16le",
"-ar", "44100",
audioPath,
];
try {
await execAsync("ffmpeg", args, { timeout: 60000 });
}
catch (err) {
throw new Error(`Audio extraction failed: ${err?.message || String(err)}`);
}
}
async runPythonPipeline(framesDir, audioPath, transcript, tmpDir) {
const possiblePaths = [
path.resolve(process.cwd(), "services/clip-scorer/pipeline.py"),
path.resolve("/app/scripts/pipeline.py"),
path.resolve(__dirname, "../../scripts/pipeline.py"),
];
let scriptPath = "";
for (const p of possiblePaths) {
try {
await fs.access(p);
scriptPath = p;
break;
}
catch {
continue;
}
}
if (!scriptPath) {
throw new Error("Python pipeline script not found. Searched: " + possiblePaths.join(", "));
}
const transcriptPath = path.join(tmpDir, "transcript.txt");
await fs.writeFile(transcriptPath, transcript, "utf8");
const args = [
scriptPath,
"--frames_dir", framesDir,
"--audio_path", audioPath,
"--transcript_path", transcriptPath,
];
const { stdout, stderr } = await execAsync("python3", args, {
timeout: 300000,
maxBuffer: 1024 * 1024,
});
if (!stdout.trim()) {
throw new Error(`Python script produced no output. stderr: ${stderr}`);
}
return JSON.parse(stdout.trim());
}
};
exports.ClipScoringProcessor = ClipScoringProcessor;
exports.ClipScoringProcessor = ClipScoringProcessor = ClipScoringProcessor_1 = __decorate([
(0, bullmq_1.Processor)("clip-scoring", {
concurrency: 2,
}),
__param(2, (0, common_1.Inject)(redis_constants_1.REDIS_CLIENT)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
r2_storage_service_1.R2StorageService, Object])
], ClipScoringProcessor);
//# sourceMappingURL=clip-scoring.processor.js.map