mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
assets and tier plans revamp
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// services/assetTranscode.service.js
|
||||
//
|
||||
// Orchestrates the background remux job for a single video asset: mints a
|
||||
// presigned read URL, hands it to ffmpeg.service, uploads the result back to
|
||||
// storage (streamed, not buffered), then swaps the asset row over to the new
|
||||
// object. Called two ways:
|
||||
// 1. Fire-and-forget from assets.controller.js#finalizeAssetFromStorage,
|
||||
// right after a .mov/.mkv upload is finalized.
|
||||
// 2. cron/jobs/retry_stuck_transcodes.cron.js — safety net for jobs that
|
||||
// never got picked up (server restarted mid-remux) or are still marked
|
||||
// "pending" (the fire-and-forget call in 1. never actually started,
|
||||
// e.g. this process crashed between the DB commit and the call).
|
||||
//
|
||||
// The original .mov/.mkv object is intentionally left in storage on success
|
||||
// — this only swaps which object the asset *plays from* (storage_key), it
|
||||
// doesn't delete anything. Reclaiming that storage is a separate decision.
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const Asset = require("../models/assets/assets.mdl");
|
||||
const s3 = require("../services/s3.service");
|
||||
const ffmpegSvc = require("../services/ffmpeg.service");
|
||||
|
||||
// ── One retry-worth of guardrails ──────────────────────────────────────────
|
||||
// Only ever the fire-and-forget call or the retry cron should be racing to
|
||||
// pick up a given asset — this claim step (pending/failed -> processing)
|
||||
// makes double-processing harmless even if both fire close together.
|
||||
async function claimForProcessing(assetId) {
|
||||
const [count] = await Asset.update(
|
||||
{ transcode_status: "processing", transcode_error: null },
|
||||
{ where: { asset_id: assetId, transcode_status: ["pending", "failed"] } },
|
||||
);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async function transcodeAsset(asset) {
|
||||
if (asset.storage_provider !== "s3" || !ffmpegSvc.needsRemux(asset.extension)) return;
|
||||
|
||||
const claimed = await claimForProcessing(asset.asset_id);
|
||||
if (!claimed) return; // already being processed, or already done
|
||||
|
||||
let outputPath = null;
|
||||
try {
|
||||
const inputUrl = await s3.getSignedDownloadUrl(asset.storage_key);
|
||||
outputPath = await ffmpegSvc.remuxToFaststartMp4(inputUrl);
|
||||
|
||||
const { size: file_size } = await fs.promises.stat(outputPath);
|
||||
const readStream = fs.createReadStream(outputPath);
|
||||
|
||||
const originalname = `${(asset.original_name || asset.uuid || "video").replace(/\.[^.]+$/, "")}.mp4`;
|
||||
const { url: file_url, uuid: storage_key } = await s3.uploadStream({
|
||||
stream: readStream,
|
||||
originalname,
|
||||
mimetype: "video/mp4",
|
||||
ownerType: "video",
|
||||
});
|
||||
|
||||
await asset.update({
|
||||
storage_key,
|
||||
file_url,
|
||||
file_size,
|
||||
mime_type: "video/mp4",
|
||||
extension: "mp4",
|
||||
transcode_status: "done",
|
||||
transcode_error: null,
|
||||
});
|
||||
|
||||
console.log(`[ASSET][TRANSCODE] Remuxed asset ${asset.asset_id} (${asset.original_name}) to faststart mp4.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[ASSET][TRANSCODE] Remux failed for asset ${asset.asset_id}:`, err.message);
|
||||
await asset.update({
|
||||
transcode_status: "failed",
|
||||
transcode_error: String(err.message || err).slice(0, 2000),
|
||||
}).catch(() => {});
|
||||
|
||||
} finally {
|
||||
if (outputPath) fs.promises.unlink(outputPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { transcodeAsset };
|
||||
@@ -0,0 +1,102 @@
|
||||
// services/ffmpeg.service.js
|
||||
//
|
||||
// Fast container remux for video assets whose original format loads slowly
|
||||
// in the browser. Not a transcode — the video/audio streams are copied
|
||||
// byte-for-byte (`-c copy`), only the container is swapped:
|
||||
//
|
||||
// .mov — often exported without "fast start" (common for OBS/QuickTime
|
||||
// screen recordings), which puts the moov atom — the index the
|
||||
// browser needs before it can render anything — at the END of the
|
||||
// file. Playback can't begin until that's reached.
|
||||
// .mkv — same class of problem with its Cues index, plus native browser
|
||||
// support for Matroska demuxing/seeking is inconsistent to begin
|
||||
// with.
|
||||
//
|
||||
// Remuxing into a faststart .mp4 (moov moved to the front) makes both play
|
||||
// exactly like this app's already-fast .mp4 uploads. .mp4/.mp3 are untouched
|
||||
// — they don't have this problem.
|
||||
//
|
||||
// Reads directly from a presigned S3 URL — ffmpeg's own HTTP client handles
|
||||
// that (same as ffprobe.service.js's probeUrl()), this process never buffers
|
||||
// the original file. The mp4 muxer needs a seekable *output* to rewrite the
|
||||
// moov atom after the fact, so the result is written to a local temp file —
|
||||
// see services/assetTranscode.service.js for streaming that back to storage
|
||||
// without buffering it into memory either.
|
||||
|
||||
const ffmpeg = require("fluent-ffmpeg");
|
||||
const ffprobeStatic = require("ffprobe-static");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
execSync("which ffprobe", { stdio: "ignore" });
|
||||
} catch {
|
||||
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
||||
}
|
||||
|
||||
const REMUXABLE_EXTENSIONS = new Set(["mov", "mkv"]);
|
||||
|
||||
function needsRemux(extension = "") {
|
||||
return REMUXABLE_EXTENSIONS.has((extension || "").toLowerCase());
|
||||
}
|
||||
|
||||
function tempOutputPath() {
|
||||
return path.join(os.tmpdir(), `remux_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.mp4`);
|
||||
}
|
||||
|
||||
// input: presigned GET URL for the original .mov/.mkv object
|
||||
// output: local filesystem path to the remuxed .mp4 (caller owns cleanup)
|
||||
//
|
||||
// -map 0:v:0 -map 0:a:0? — take the first video stream and, if present, the
|
||||
// first audio stream only. Drops subtitle/data streams some mkv/mov files
|
||||
// carry, which the mp4 muxer either can't hold or chokes on.
|
||||
// -max_muxing_queue_size — defensive bump; large copy-remuxes of files with
|
||||
// bursty interleaving can otherwise hit "Too many packets buffered for
|
||||
// output stream" and abort.
|
||||
function remuxToFaststartMp4(inputUrl, { timeoutMs = 30 * 60 * 1000 } = {}) {
|
||||
const outputPath = tempOutputPath();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const command = ffmpeg(inputUrl)
|
||||
.outputOptions([
|
||||
"-map 0:v:0",
|
||||
"-map 0:a:0?",
|
||||
"-c:v copy",
|
||||
"-c:a copy",
|
||||
"-movflags +faststart",
|
||||
"-max_muxing_queue_size 9999",
|
||||
])
|
||||
.format("mp4");
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
command.kill("SIGKILL");
|
||||
fs.promises.unlink(outputPath).catch(() => {});
|
||||
reject(new Error(`Remux timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
command
|
||||
.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fs.promises.unlink(outputPath).catch(() => {});
|
||||
reject(err);
|
||||
})
|
||||
.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(outputPath);
|
||||
})
|
||||
.save(outputPath);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { needsRemux, remuxToFaststartMp4 };
|
||||
+33
-1
@@ -184,6 +184,38 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── uploadStream ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Same as uploadFile(), but Body is a Node Readable stream instead of a
|
||||
// Buffer — used by assetTranscode.service.js to push a remuxed video back to
|
||||
// storage straight off local disk, without ever holding the whole (possibly
|
||||
// multi-GB) file in this process's memory. Upload (lib-storage) auto-chunks
|
||||
// a stream body into multipart the same way it does a large Buffer.
|
||||
//
|
||||
// input: { stream, originalname, mimetype, ownerType? }
|
||||
// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB
|
||||
//
|
||||
async function uploadStream({ stream, originalname, mimetype, ownerType = "video" }) {
|
||||
if (!stream) {
|
||||
throw Object.assign(new Error("A readable stream is required for S3 uploads."), { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = DEFAULT_BUCKET;
|
||||
const key = buildKey(originalname, ownerType);
|
||||
|
||||
const uploader = new Upload({
|
||||
client: s3,
|
||||
params: { Bucket: bucket, Key: key, Body: stream, ContentType: mimetype },
|
||||
});
|
||||
|
||||
await uploader.done();
|
||||
|
||||
return {
|
||||
url: await buildPublicUrl(key, bucket),
|
||||
uuid: key,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deleteFile ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Matches chibisafe.service.js interface.
|
||||
@@ -412,7 +444,7 @@ async function getFileMetadata(key) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
|
||||
uploadFile, uploadStream, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
|
||||
presignUpload, completeMultipartUpload, abortMultipartUpload,
|
||||
getFileMetadata, buildPublicUrl, ping,
|
||||
};
|
||||
Reference in New Issue
Block a user