feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
-29
View File
@@ -1,29 +0,0 @@
'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 __exportStar =
(this && this.__exportStar) ||
function (m, exports) {
for (var p in m)
if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, '__esModule', { value: true });
__exportStar(require('./send'), exports);
-217
View File
@@ -1,217 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processSendEmail = processSendEmail;
var email_1 = require('@cio/email');
var jobs_1 = require('@cio/jobs');
var logger_1 = require('../../utils/logger');
/**
* Process an `emails:send` job. Dispatches based on payload `kind`, calls the
* provider via `@cio/email`, and returns a provider message id (BullMQ stores
* this as the job return value).
*/
function processSendEmail(rawPayload) {
return __awaiter(this, void 0, void 0, function () {
var payload, responses_1, result_1, responses, result;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
payload = jobs_1.ZSendEmailPayload.parse(rawPayload);
if (!(payload.kind === 'template')) return [3 /*break*/, 2];
return [
4 /*yield*/,
(0, email_1.sendEmail)(payload.template, {
to: payload.to,
// Re-validated by `sendEmail` against the registered template schema; the
// generic record was already validated in the API helper before enqueue.
fields: payload.fields,
from: payload.from,
replyTo: payload.replyTo
})
];
case 1:
responses_1 = _a.sent();
result_1 = extractProviderId(responses_1);
logger_1.log.info('email-sent', {
kind: 'template',
template: payload.template,
recipient: payload.to,
providerId: result_1.providerId
});
return [2 /*return*/, result_1];
case 2:
return [
4 /*yield*/,
(0, email_1.deliverEmail)([
{
to: payload.to,
subject: payload.subject,
content: payload.content,
from: payload.from,
replyTo: payload.replyTo
}
])
];
case 3:
responses = _a.sent();
result = extractProviderId(responses);
logger_1.log.info('email-sent', { kind: 'raw', recipient: payload.to, providerId: result.providerId });
return [2 /*return*/, result];
}
});
});
}
function extractProviderId(responses) {
var _a, _b, _c, _d;
var failed = responses.find(function (response) {
return !response.success;
});
if (failed) {
throw new Error(
(_a = failed.error) !== null && _a !== void 0 ? _a : 'email provider returned an unsuccessful response'
);
}
for (var _i = 0, responses_2 = responses; _i < responses_2.length; _i++) {
var response = responses_2[_i];
var details = response.details;
var candidate =
(_c =
(_b = details === null || details === void 0 ? void 0 : details.messageId) !== null && _b !== void 0
? _b
: details === null || details === void 0
? void 0
: details.request_id) !== null && _c !== void 0
? _c
: (_d = details === null || details === void 0 ? void 0 : details.data) === null || _d === void 0
? void 0
: _d.request_id;
if (candidate) {
return { providerId: String(candidate) };
}
}
return { providerId: '' };
}
@@ -1,309 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processExtractAudio = processExtractAudio;
var promises_1 = require('node:fs/promises');
var node_path_1 = require('node:path');
var node_os_1 = require('node:os');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'extract-audio';
var DOMAIN = 'media';
function transcodeToMp3(inputPath, outputPath) {
return (0, ffmpeg_1.ffmpegRun)([
'-y',
'-i',
inputPath,
'-vn',
'-acodec',
'libmp3lame',
'-b:a',
'32k',
'-ac',
'1',
'-ar',
'16000',
'-f',
'mp3',
outputPath
]);
}
/**
* Extract a 16 kHz mono MP3 (32 kbps) from the source video for Whisper (25 MB
* API limit). The transcribe step reads the object from the media bucket.
*/
function processExtractAudio(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
storageKey,
existing,
inputPath,
outputPath,
dir,
audioKey,
fileStat,
result,
error_1;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
payload = media_1.ZExtractAudioPayload.parse(rawData);
((mediaJobId = payload.mediaJobId), (assetId = payload.assetId), (storageKey = payload.storageKey));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _b.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('extract-audio-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'extracting-audio', progressPercent: 70 })
];
case 3:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_b.sent();
_b.label = 5;
case 5:
_b.trys.push([5, 14, 16, 19]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'audio-source.bin')
];
case 6:
inputPath = _b.sent();
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs', 'audio');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 7:
_b.sent();
outputPath = node_path_1.default.join(dir, ''.concat(mediaJobId, '.mp3'));
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_b.sent();
return [4 /*yield*/, transcodeToMp3(inputPath, outputPath)];
case 9:
_b.sent();
audioKey = 'audio/'.concat(assetId, '/').concat(mediaJobId, '.mp3');
return [
4 /*yield*/,
(0, storage_1.uploadFileToBucket)(
(0, storage_1.mediaBucket)(),
audioKey,
outputPath,
'audio/mpeg',
'private, max-age=0'
)
];
case 10:
_b.sent();
return [4 /*yield*/, (0, promises_1.stat)(outputPath)];
case 11:
fileStat = _b.sent();
result = { bucket: (0, storage_1.mediaBucket)(), key: audioKey, fileSizeBytes: fileStat.size };
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 12:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'audio-ready', progressPercent: 80 })
];
case 13:
_b.sent();
logger_1.log.info('extract-audio-done', { mediaJobId: mediaJobId, audioKey: audioKey });
return [2 /*return*/, result];
case 14:
error_1 = _b.sent();
logger_1.log.error('extract-audio-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'EXTRACT_AUDIO_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 15:
_b.sent();
throw error_1;
case 16:
return [4 /*yield*/, (0, storage_1.safeUnlink)(inputPath)];
case 17:
_b.sent();
return [4 /*yield*/, (0, storage_1.safeUnlink)(outputPath)];
case 18:
_b.sent();
return [7 /*endfinally*/];
case 19:
return [2 /*return*/];
}
});
});
}
@@ -1,338 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processGenerateThumbnail = processGenerateThumbnail;
var promises_1 = require('node:fs/promises');
var node_path_1 = require('node:path');
var node_os_1 = require('node:os');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var storage_2 = require('../../config/storage');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'generate-thumbnail';
var DOMAIN = 'media';
function extractThumbnail(localPath, atSeconds, outputPath) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
return [
4 /*yield*/,
(0, ffmpeg_1.ffmpegRun)([
'-y',
'-ss',
String(atSeconds),
'-i',
localPath,
'-vframes',
'1',
'-vf',
'scale=640:-1',
outputPath
])
];
case 1:
_a.sent();
return [2 /*return*/];
}
});
});
}
/**
* Render a single JPEG frame to use as the asset thumbnail. Replaces the
* browser-side thumbnail as the source of truth on the asset row.
*/
function processGenerateThumbnail(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
storageKey,
actorContext,
_a,
atSeconds,
existing,
localPath,
outputPath,
dir,
buffer,
thumbKey,
baseUrl,
publicUrl,
result,
error_1;
var _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
payload = media_1.ZGenerateThumbnailPayload.parse(rawData);
((mediaJobId = payload.mediaJobId),
(assetId = payload.assetId),
(storageKey = payload.storageKey),
(actorContext = payload.actorContext),
(_a = payload.atSeconds),
(atSeconds = _a === void 0 ? 1.0 : _a));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _c.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('generate-thumbnail-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'thumbnailing', progressPercent: 35 })
];
case 3:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_b = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _b !== void 0
? _b
: 0) + 1
})
];
case 4:
_c.sent();
_c.label = 5;
case 5:
_c.trys.push([5, 15, 17, 20]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'thumb-source.bin')
];
case 6:
localPath = _c.sent();
dir = node_path_1.default.join((0, node_os_1.tmpdir)(), 'cio-jobs', 'thumbs');
return [4 /*yield*/, (0, promises_1.mkdir)(dir, { recursive: true })];
case 7:
_c.sent();
outputPath = node_path_1.default.join(dir, ''.concat(mediaJobId, '.jpg'));
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_c.sent();
return [4 /*yield*/, extractThumbnail(localPath, atSeconds, outputPath)];
case 9:
_c.sent();
return [4 /*yield*/, (0, promises_1.readFile)(outputPath)];
case 10:
buffer = _c.sent();
thumbKey = 'thumbnails/'.concat(assetId, '/').concat(Date.now(), '.jpg');
return [
4 /*yield*/,
(0, storage_1.uploadBufferToBucket)(
(0, storage_1.mediaBucket)(),
thumbKey,
buffer,
'image/jpeg',
'public, max-age=31536000'
)
];
case 11:
_c.sent();
baseUrl = (0, storage_2.getStorageConfig)().mediaPublicBaseUrl;
publicUrl = baseUrl ? ''.concat(baseUrl.replace(/\/$/, ''), '/').concat(thumbKey) : null;
result = { bucket: (0, storage_1.mediaBucket)(), key: thumbKey, publicUrl: publicUrl };
return [
4 /*yield*/,
(0, queries_1.updateAsset)(assetId, actorContext.organizationId, {
thumbnailUrl: publicUrl !== null && publicUrl !== void 0 ? publicUrl : undefined
})
];
case 12:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 13:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'thumbnailed', progressPercent: 60 })
];
case 14:
_c.sent();
logger_1.log.info('generate-thumbnail-done', { mediaJobId: mediaJobId, thumbKey: thumbKey });
return [2 /*return*/, result];
case 15:
error_1 = _c.sent();
logger_1.log.error('generate-thumbnail-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'THUMBNAIL_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 16:
_c.sent();
throw error_1;
case 17:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 18:
_c.sent();
return [4 /*yield*/, (0, storage_1.safeUnlink)(outputPath)];
case 19:
_c.sent();
return [7 /*endfinally*/];
case 20:
return [2 /*return*/];
}
});
});
}
@@ -27,6 +27,13 @@ type Slot = (typeof SLOT_ORDER)[number];
/** Fallback time offsets (seconds) tried when a slot's primary frame is black. */
const FALLBACK_OFFSETS = [0, -1.5, 1.5];
const DEFAULT_DURATION_SECONDS = 10;
/** Hard ceiling for a stored thumbnail. Frames over this are re-encoded at lower quality. */
const MAX_THUMBNAIL_BYTES = 500 * 1024;
/**
* mjpeg `-q:v` ladder (2 = best/largest, 31 = worst/smallest). We start at the
* top and step down only when a frame exceeds MAX_THUMBNAIL_BYTES.
*/
const QSCALE_LADDER = [2, 5, 9, 15] as const;
interface CandidateUpload {
slot: Slot;
@@ -58,7 +65,12 @@ function targetForSlot(slot: Slot, duration: number): number {
* keyframe and is the most common source of black thumbnails for codecs whose
* first keyframe sits past the requested offset.
*/
async function extractFrame(localPath: string, atSeconds: number, outputPath: string): Promise<void> {
async function extractFrame(
localPath: string,
atSeconds: number,
outputPath: string,
qscale: number = QSCALE_LADDER[0]
): Promise<void> {
await ffmpegRun([
'-y',
'-i',
@@ -68,11 +80,38 @@ async function extractFrame(localPath: string, atSeconds: number, outputPath: st
'-vframes',
'1',
'-vf',
'scale=640:-1',
"scale='min(1280,iw)':-2",
'-q:v',
String(qscale),
outputPath
]);
}
/**
* Re-encode the frame at `atSeconds` with progressively lower quality until it
* fits under MAX_THUMBNAIL_BYTES. Returns the smallest buffer produced if even
* the worst rung overshoots. Assumes the frame was already extracted once at
* QSCALE_LADDER[0].
*/
async function encodeUnderSizeCap(
localPath: string,
atSeconds: number,
outputPath: string,
buffer: Buffer
): Promise<Buffer> {
if (buffer.length <= MAX_THUMBNAIL_BYTES) return buffer;
let smallest = buffer;
for (const qscale of QSCALE_LADDER.slice(1)) {
await extractFrame(localPath, atSeconds, outputPath, qscale);
const reencoded = await readFile(outputPath);
if (reencoded.length < smallest.length) smallest = reencoded;
if (reencoded.length <= MAX_THUMBNAIL_BYTES) return reencoded;
}
return smallest;
}
async function captureSlot(
localPath: string,
slot: Slot,
@@ -86,7 +125,8 @@ async function captureSlot(
await extractFrame(localPath, t, outputPath);
const luma = await ffmpegProbeLuma(outputPath);
if (luma >= BLACK_LUMA_THRESHOLD) {
const buffer = await readFile(outputPath);
const raw = await readFile(outputPath);
const buffer = await encodeUnderSizeCap(localPath, t, outputPath, raw);
return { buffer, meanLuma: luma, atSeconds: t };
}
log.warn('generate-thumbnail-black-frame', { slot, atSeconds: t, luma });
-32
View File
@@ -1,32 +0,0 @@
'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 __exportStar =
(this && this.__exportStar) ||
function (m, exports) {
for (var p in m)
if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, '__esModule', { value: true });
__exportStar(require('./probe-metadata'), exports);
__exportStar(require('./generate-thumbnail'), exports);
__exportStar(require('./extract-audio'), exports);
__exportStar(require('./transcribe-audio'), exports);
@@ -1,311 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processProbeMetadata = processProbeMetadata;
var promises_1 = require('node:fs/promises');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var ffmpeg_1 = require('../../utils/ffmpeg');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'probe-metadata';
var DOMAIN = 'media';
function parseProbe(metadata, fileSizeBytes) {
var _a, _b;
var videoStream = metadata.streams.find(function (stream) {
return stream.codec_type === 'video';
});
var audioStream = metadata.streams.find(function (stream) {
return stream.codec_type === 'audio';
});
var duration = Number(
(_b =
(_a = metadata.format.duration) !== null && _a !== void 0
? _a
: videoStream === null || videoStream === void 0
? void 0
: videoStream.duration) !== null && _b !== void 0
? _b
: 0
);
return {
durationSeconds: Number.isFinite(duration) ? Math.max(0, Math.round(duration)) : 0,
width: videoStream === null || videoStream === void 0 ? void 0 : videoStream.width,
height: videoStream === null || videoStream === void 0 ? void 0 : videoStream.height,
videoCodec: videoStream === null || videoStream === void 0 ? void 0 : videoStream.codec_name,
audioCodec: audioStream === null || audioStream === void 0 ? void 0 : audioStream.codec_name,
hasAudio: Boolean(audioStream),
fileSizeBytes: fileSizeBytes
};
}
/**
* Probe authoritative metadata (duration, codecs, dimensions) using ffprobe.
* Replaces the browser-derived duration on the asset row.
*/
function processProbeMetadata(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload, mediaJobId, assetId, storageKey, actorContext, existing, localPath, fileStat, probe, result, error_1;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
payload = media_1.ZProbeMetadataPayload.parse(rawData);
((mediaJobId = payload.mediaJobId),
(assetId = payload.assetId),
(storageKey = payload.storageKey),
(actorContext = payload.actorContext));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _b.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('probe-metadata-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { status: 'running', stage: 'probing', progressPercent: 5 })
];
case 3:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_b.sent();
_b.label = 5;
case 5:
_b.trys.push([5, 13, 15, 17]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)((0, storage_1.videosBucket)(), storageKey, 'source.bin')
];
case 6:
localPath = _b.sent();
return [4 /*yield*/, (0, promises_1.stat)(localPath)];
case 7:
fileStat = _b.sent();
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 8:
_b.sent();
return [4 /*yield*/, (0, ffmpeg_1.ffprobeJson)(localPath)];
case 9:
probe = _b.sent();
result = parseProbe(probe, fileStat.size);
return [
4 /*yield*/,
(0, queries_1.updateAsset)(assetId, actorContext.organizationId, {
durationSeconds: result.durationSeconds,
aspectRatio: result.width && result.height ? ''.concat(result.width, ':').concat(result.height) : null,
metadata: {
probe: {
durationSeconds: result.durationSeconds,
width: result.width,
height: result.height,
videoCodec: result.videoCodec,
audioCodec: result.audioCodec,
hasAudio: result.hasAudio,
fileSizeBytes: result.fileSizeBytes,
probedAt: new Date().toISOString()
}
}
})
];
case 10:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 11:
_b.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
stage: 'probed',
progressPercent: 25
})
];
case 12:
_b.sent();
logger_1.log.info('probe-metadata-done', { mediaJobId: mediaJobId, durationSeconds: result.durationSeconds });
return [2 /*return*/, result];
case 13:
error_1 = _b.sent();
logger_1.log.error('probe-metadata-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_1)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'PROBE_FAILED', message: (0, cancel_1.errorMessage)(error_1) }
})
];
case 14:
_b.sent();
throw error_1;
case 15:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 16:
_b.sent();
return [7 /*endfinally*/];
case 17:
return [2 /*return*/];
}
});
});
}
@@ -1,538 +0,0 @@
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __spreadArray =
(this && this.__spreadArray) ||
function (to, from, pack) {
if (pack || arguments.length === 2)
for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.processTranscribeAudio = processTranscribeAudio;
var promises_1 = require('node:fs/promises');
var queries_1 = require('@cio/db/queries');
var media_1 = require('@cio/jobs/payloads/media');
var env_1 = require('../../config/env');
var openai_1 = require('../../services/transcription/openai');
var vtt_1 = require('../../services/transcription/vtt');
var storage_1 = require('../../utils/storage');
var cancel_1 = require('../../utils/cancel');
var logger_1 = require('../../utils/logger');
var STEP_KEY = 'transcribe-audio';
var DOMAIN = 'media';
var EXTRACT_STEP_KEY = 'extract-audio';
function transcriptionCostCents(durationSeconds) {
return Math.ceil((durationSeconds / 60) * 0.6);
}
function mergeJobWarnings(mediaJobId, extra) {
return __awaiter(this, void 0, void 0, function () {
var job, prior;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
return [4 /*yield*/, (0, queries_1.getMediaJobById)(mediaJobId)];
case 1:
job = _b.sent();
prior = (_a = job === null || job === void 0 ? void 0 : job.warnings) !== null && _a !== void 0 ? _a : [];
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
warnings: __spreadArray(__spreadArray([], prior, true), extra, true)
})
];
case 2:
_b.sent();
return [2 /*return*/];
}
});
});
}
/**
* OpenAI Whisper (`whisper-1`) with verbose segment timestamps; persists VTT +
* `media_transcript` row. Skips cleanly when `OPENAI_API_KEY` is unset or when
* extracted audio exceeds the 25 MB API limit.
*/
function processTranscribeAudio(rawData) {
return __awaiter(this, void 0, void 0, function () {
var payload,
mediaJobId,
assetId,
actorContext,
existing,
result,
extractStep,
extractResult,
message,
localPath,
localStat,
whisper,
normalizedSegments,
vttBody,
vttKey,
bucket,
transcriptCost,
jobRow,
priorCost,
result,
error_1,
error_2;
var _a, _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
payload = media_1.ZTranscribeAudioPayload.parse(rawData);
((mediaJobId = payload.mediaJobId), (assetId = payload.assetId), (actorContext = payload.actorContext));
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, STEP_KEY)];
case 1:
existing = _c.sent();
if (
(existing === null || existing === void 0 ? void 0 : existing.status) === 'completed' &&
existing.result
) {
logger_1.log.info('transcribe-audio-skip', { mediaJobId: mediaJobId, reason: 'ledger-completed' });
return [2 /*return*/, existing.result];
}
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 2:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, { stage: 'transcribing', progressPercent: 90 })
];
case 3:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'running',
startedAt: new Date().toISOString(),
attempt:
((_a = existing === null || existing === void 0 ? void 0 : existing.attempt) !== null && _a !== void 0
? _a
: 0) + 1
})
];
case 4:
_c.sent();
_c.label = 5;
case 5:
_c.trys.push([5, 35, , 37]);
if (!!env_1.env.OPENAI_API_KEY) return [3 /*break*/, 9];
result = { provider: 'skipped', text: '' };
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: result
})
];
case 6:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-no-openai-key'])];
case 7:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 8:
_c.sent();
logger_1.log.info('transcribe-audio-skipped', { mediaJobId: mediaJobId, reason: 'no-openai-key' });
return [2 /*return*/, result];
case 9:
return [4 /*yield*/, (0, queries_1.getJobStep)(DOMAIN, mediaJobId, EXTRACT_STEP_KEY)];
case 10:
extractStep = _c.sent();
extractResult = extractStep === null || extractStep === void 0 ? void 0 : extractStep.result;
if (
!(
!(extractResult === null || extractResult === void 0 ? void 0 : extractResult.bucket) ||
!(extractResult === null || extractResult === void 0 ? void 0 : extractResult.key)
)
)
return [3 /*break*/, 12];
message = 'extract-audio step missing or incomplete';
logger_1.log.error('transcribe-audio-failed', { mediaJobId: mediaJobId, error: message });
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'EXTRACT_AUDIO_MISSING', message: message }
})
];
case 11:
_c.sent();
throw new Error(message);
case 12:
localPath = void 0;
_c.label = 13;
case 13:
_c.trys.push([13, 27, 32, 34]);
return [
4 /*yield*/,
(0, storage_1.downloadObjectToTempFile)(extractResult.bucket, extractResult.key, 'whisper-input.mp3')
];
case 14:
localPath = _c.sent();
return [4 /*yield*/, (0, promises_1.stat)(localPath)];
case 15:
localStat = _c.sent();
if (!(localStat.size > openai_1.WHISPER_MAX_FILE_BYTES)) return [3 /*break*/, 19];
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: {
code: 'AUDIO_TOO_LARGE',
message: 'Audio '.concat(localStat.size, ' bytes exceeds ').concat(openai_1.WHISPER_MAX_FILE_BYTES)
}
})
];
case 16:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-audio-too-large'])];
case 17:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 18:
_c.sent();
logger_1.log.warn('transcribe-audio-skipped-audio-too-large', {
mediaJobId: mediaJobId,
fileSizeBytes: localStat.size
});
return [2 /*return*/, { provider: 'skipped', text: '' }];
case 19:
return [4 /*yield*/, (0, cancel_1.throwIfCancelRequested)(mediaJobId)];
case 20:
_c.sent();
return [
4 /*yield*/,
(0, openai_1.transcribeAudioFile)({
apiKey: env_1.env.OPENAI_API_KEY,
localPath: localPath
})
];
case 21:
whisper = _c.sent();
normalizedSegments = whisper.segments.map(function (segment) {
return {
start: segment.start,
end: segment.end,
text: segment.text
};
});
vttBody = (0, vtt_1.segmentsToWebVtt)(normalizedSegments);
vttKey = 'transcripts/'.concat(assetId, '/').concat(mediaJobId, '.vtt');
bucket = (0, storage_1.mediaBucket)();
return [
4 /*yield*/,
(0, storage_1.uploadBufferToBucket)(
bucket,
vttKey,
Buffer.from(vttBody, 'utf8'),
'text/vtt; charset=utf-8',
'private, max-age=0'
)
];
case 22:
_c.sent();
transcriptCost = transcriptionCostCents(whisper.durationSeconds);
return [4 /*yield*/, (0, queries_1.getMediaJobById)(mediaJobId)];
case 23:
jobRow = _c.sent();
priorCost =
(_b = jobRow === null || jobRow === void 0 ? void 0 : jobRow.costCents) !== null && _b !== void 0 ? _b : 0;
return [
4 /*yield*/,
(0, queries_1.upsertMediaTranscript)({
organizationId: actorContext.organizationId,
assetId: assetId,
mediaJobId: mediaJobId,
language: whisper.language,
provider: 'openai',
model: 'whisper-1',
text:
whisper.fullText ||
normalizedSegments
.map(function (segment) {
return segment.text;
})
.join(' ')
.trim(),
segments: normalizedSegments,
vttStorageKey: vttKey,
vttBucket: bucket,
durationSeconds: Math.round(whisper.durationSeconds),
costCents: transcriptCost
})
];
case 24:
_c.sent();
result = {
provider: 'openai',
text: whisper.fullText,
language: whisper.language,
durationSeconds: whisper.durationSeconds,
costCents: transcriptCost
};
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'completed',
finishedAt: new Date().toISOString(),
result: {
language: whisper.language,
segmentCount: normalizedSegments.length,
vttKey: vttKey,
costCents: transcriptCost
},
providerId: null
})
];
case 25:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100,
costCents: priorCost + transcriptCost
})
];
case 26:
_c.sent();
logger_1.log.info('transcribe-audio-done', {
mediaJobId: mediaJobId,
language: whisper.language,
segmentCount: normalizedSegments.length
});
return [2 /*return*/, result];
case 27:
error_1 = _c.sent();
if (!(error_1 instanceof openai_1.AudioFileTooLargeError)) return [3 /*break*/, 31];
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'AUDIO_TOO_LARGE', message: error_1.message }
})
];
case 28:
_c.sent();
return [4 /*yield*/, mergeJobWarnings(mediaJobId, ['transcription-skipped-audio-too-large'])];
case 29:
_c.sent();
return [
4 /*yield*/,
(0, queries_1.updateMediaJob)(mediaJobId, {
status: 'completed',
stage: 'done',
progressPercent: 100
})
];
case 30:
_c.sent();
logger_1.log.warn('transcribe-audio-skipped-audio-too-large', {
mediaJobId: mediaJobId,
fileSizeBytes: error_1.fileSizeBytes
});
return [2 /*return*/, { provider: 'skipped', text: '' }];
case 31:
throw error_1;
case 32:
return [4 /*yield*/, (0, storage_1.safeUnlink)(localPath)];
case 33:
_c.sent();
return [7 /*endfinally*/];
case 34:
return [3 /*break*/, 37];
case 35:
error_2 = _c.sent();
logger_1.log.error('transcribe-audio-failed', {
mediaJobId: mediaJobId,
error: (0, cancel_1.errorMessage)(error_2)
});
return [
4 /*yield*/,
(0, queries_1.upsertJobStep)({
domain: DOMAIN,
runId: mediaJobId,
stepKey: STEP_KEY,
status: 'failed',
finishedAt: new Date().toISOString(),
error: { code: 'TRANSCRIBE_FAILED', message: (0, cancel_1.errorMessage)(error_2) }
})
];
case 36:
_c.sent();
throw error_2;
case 37:
return [2 /*return*/];
}
});
});
}