@hed-hog/lms 0.0.353 → 0.0.354
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/course/course-audio-transcription.service.d.ts +29 -0
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
- package/dist/course/course-audio-transcription.service.js +291 -0
- package/dist/course/course-audio-transcription.service.js.map +1 -0
- package/dist/course/course-lesson.controller.d.ts +10 -0
- package/dist/course/course-lesson.controller.d.ts.map +1 -0
- package/dist/course/course-lesson.controller.js +62 -0
- package/dist/course/course-lesson.controller.js.map +1 -0
- package/dist/course/course-structure.controller.d.ts +41 -15
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +50 -6
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +50 -15
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +238 -73
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-video-conversion.service.d.ts +20 -2
- package/dist/course/course-video-conversion.service.d.ts.map +1 -1
- package/dist/course/course-video-conversion.service.js +730 -10
- package/dist/course/course-video-conversion.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +24 -8
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +5 -3
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +24 -8
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +112 -176
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
- package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
- package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
- package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +1 -1
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js +6 -6
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
- package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
- package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
- package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
- package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
- package/dist/course/dto/update-course-resources.dto.js +10 -3
- package/dist/course/dto/update-course-resources.dto.js.map +1 -1
- package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
- package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
- package/dist/course/dto/update-transcription-segments.dto.js +38 -0
- package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
- package/dist/course/lms-setting.controller.d.ts +13 -0
- package/dist/course/lms-setting.controller.d.ts.map +1 -0
- package/dist/course/lms-setting.controller.js +53 -0
- package/dist/course/lms-setting.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.js +74 -33
- package/dist/enterprise/training/training-admin.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +6 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/route.yaml +63 -0
- package/hedhog/data/setting_group.yaml +76 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
- package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
- package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
- package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
- package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
- package/hedhog/frontend/messages/en.json +39 -3
- package/hedhog/frontend/messages/pt.json +39 -3
- package/hedhog/table/course.yaml +8 -0
- package/hedhog/table/course_lesson_file.yaml +12 -4
- package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
- package/hedhog/table/course_lesson_video_frame.yaml +25 -0
- package/package.json +9 -9
- package/src/course/course-audio-transcription.service.ts +393 -0
- package/src/course/course-lesson.controller.ts +28 -0
- package/src/course/course-structure.controller.ts +49 -3
- package/src/course/course-structure.service.ts +294 -32
- package/src/course/course-video-conversion.service.ts +972 -6
- package/src/course/course.module.ts +5 -3
- package/src/course/course.service.ts +87 -139
- package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
- package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
- package/src/course/dto/create-course.dto.ts +5 -5
- package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
- package/src/course/dto/update-course-resources.dto.ts +18 -3
- package/src/course/dto/update-transcription-segments.dto.ts +20 -0
- package/src/course/lms-setting.controller.ts +30 -0
- package/src/enterprise/training/training-admin.service.ts +77 -24
- package/src/index.ts +2 -0
- package/src/lms.module.ts +6 -0
- package/hedhog/table/course_instructor.yaml +0 -27
|
@@ -26,9 +26,64 @@ const util_1 = require("util");
|
|
|
26
26
|
exports.LMS_VIDEO_CONVERSION_JOB = 'lms.video.convert';
|
|
27
27
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
28
28
|
let CourseVideoConversionService = CourseVideoConversionService_1 = class CourseVideoConversionService {
|
|
29
|
-
|
|
29
|
+
async createProgressEvent(queueJobId, message, metadata, notificationContext) {
|
|
30
|
+
try {
|
|
31
|
+
await this.prisma.queue_job_event.create({
|
|
32
|
+
data: Object.assign({ queue_job_id: queueJobId, event_type: 'started', message }, (metadata ? { metadata } : {})),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
this.logger.warn(`Queue job ${queueJobId}: failed to persist progress event "${message}": ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
37
|
+
}
|
|
38
|
+
if (notificationContext) {
|
|
39
|
+
const progress = this.resolveNotificationProgress(metadata);
|
|
40
|
+
await this.updateAsyncNotification(notificationContext, progress, message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
resolveNotificationProgress(metadata) {
|
|
44
|
+
var _a, _b, _c;
|
|
45
|
+
const phase = String((_a = metadata === null || metadata === void 0 ? void 0 : metadata.phase) !== null && _a !== void 0 ? _a : '');
|
|
46
|
+
if (phase === 'download_original')
|
|
47
|
+
return 5;
|
|
48
|
+
if (phase === 'probe_duration')
|
|
49
|
+
return 10;
|
|
50
|
+
if (phase === 'convert_profile') {
|
|
51
|
+
const profileIndex = Number((_b = metadata === null || metadata === void 0 ? void 0 : metadata.profileIndex) !== null && _b !== void 0 ? _b : 0);
|
|
52
|
+
const profileCount = Number((_c = metadata === null || metadata === void 0 ? void 0 : metadata.profileCount) !== null && _c !== void 0 ? _c : 0);
|
|
53
|
+
if (profileIndex > 0 && profileCount > 0) {
|
|
54
|
+
const ratio = Math.min(1, Math.max(0, profileIndex / profileCount));
|
|
55
|
+
return 10 + Math.round(ratio * 45);
|
|
56
|
+
}
|
|
57
|
+
return 40;
|
|
58
|
+
}
|
|
59
|
+
if (phase === 'extract_frames')
|
|
60
|
+
return 65;
|
|
61
|
+
if (phase === 'extract_frames_done')
|
|
62
|
+
return 72;
|
|
63
|
+
if (phase === 'extract_audio')
|
|
64
|
+
return 80;
|
|
65
|
+
if (phase === 'queue_transcription')
|
|
66
|
+
return 88;
|
|
67
|
+
if (phase === 'queue_transcription_done')
|
|
68
|
+
return 90;
|
|
69
|
+
if (phase === 'queue_transcription_skipped')
|
|
70
|
+
return 100;
|
|
71
|
+
return 15;
|
|
72
|
+
}
|
|
73
|
+
async updateAsyncNotification(context, progress, body, success) {
|
|
74
|
+
try {
|
|
75
|
+
await this.notificationService.updateProgress(context.userId, context.notificationId, Object.assign({ progress,
|
|
76
|
+
body }, (success != null ? { success } : {})));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
this.logger.warn(`Failed to update async notification ${context.notificationId} for user ${context.userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
constructor(prisma, fileService, settingService, notificationService, registry, queueJob) {
|
|
30
83
|
this.prisma = prisma;
|
|
31
84
|
this.fileService = fileService;
|
|
85
|
+
this.settingService = settingService;
|
|
86
|
+
this.notificationService = notificationService;
|
|
32
87
|
this.registry = registry;
|
|
33
88
|
this.queueJob = queueJob;
|
|
34
89
|
this.logger = new common_1.Logger(CourseVideoConversionService_1.name);
|
|
@@ -39,6 +94,11 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
39
94
|
}
|
|
40
95
|
async enqueueConversion(params) {
|
|
41
96
|
var _a, _b, _c;
|
|
97
|
+
const settings = await this.settingService.getSettingValues('lms-video-conversion-enabled');
|
|
98
|
+
const isVideoConversionEnabled = settings['lms-video-conversion-enabled'] !== false;
|
|
99
|
+
if (!isVideoConversionEnabled) {
|
|
100
|
+
throw new common_1.BadRequestException('Video conversion queue is disabled by settings');
|
|
101
|
+
}
|
|
42
102
|
const lesson = await this.prisma.course_lesson.findFirst({
|
|
43
103
|
where: {
|
|
44
104
|
id: params.lessonId,
|
|
@@ -80,6 +140,22 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
80
140
|
public: false,
|
|
81
141
|
overwrite: true,
|
|
82
142
|
});
|
|
143
|
+
const asyncNotification = await this.notificationService.create({
|
|
144
|
+
user_id: params.userId,
|
|
145
|
+
title: 'Conversão e transcrição de vídeo em andamento',
|
|
146
|
+
body: 'Preparando pipeline de processamento do vídeo...',
|
|
147
|
+
type: 'progress',
|
|
148
|
+
progress: 1,
|
|
149
|
+
started_at: new Date().toISOString(),
|
|
150
|
+
auto_remove: false,
|
|
151
|
+
action_type: 'url',
|
|
152
|
+
action_url: '/queue/jobs',
|
|
153
|
+
action_data: {
|
|
154
|
+
source: 'lms-video-conversion',
|
|
155
|
+
courseId: params.courseId,
|
|
156
|
+
lessonId: params.lessonId,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
83
159
|
const job = await this.queueJob.enqueue({
|
|
84
160
|
type: exports.LMS_VIDEO_CONVERSION_JOB,
|
|
85
161
|
queueName: exports.LMS_VIDEO_CONVERSION_JOB,
|
|
@@ -90,6 +166,8 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
90
166
|
originalFileId: params.originalFileId,
|
|
91
167
|
profiles,
|
|
92
168
|
overwrite: true,
|
|
169
|
+
notificationId: asyncNotification.id,
|
|
170
|
+
notificationUserId: params.userId,
|
|
93
171
|
},
|
|
94
172
|
sourceModule: 'lms',
|
|
95
173
|
sourceEntity: 'course_lesson',
|
|
@@ -105,8 +183,18 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
105
183
|
return { queueJobId: job.id, status: 'queued' };
|
|
106
184
|
}
|
|
107
185
|
async handle(job) {
|
|
108
|
-
var _a, _b;
|
|
186
|
+
var _a, _b, _c, _d, _e, _f;
|
|
109
187
|
const { courseId, sessionId, lessonId, originalFileId, profiles, overwrite } = job.payload;
|
|
188
|
+
const notificationContext = Number.isInteger(Number((_a = job.payload) === null || _a === void 0 ? void 0 : _a.notificationId)) &&
|
|
189
|
+
Number.isInteger(Number((_b = job.payload) === null || _b === void 0 ? void 0 : _b.notificationUserId))
|
|
190
|
+
? {
|
|
191
|
+
notificationId: Number(job.payload.notificationId),
|
|
192
|
+
userId: Number(job.payload.notificationUserId),
|
|
193
|
+
}
|
|
194
|
+
: undefined;
|
|
195
|
+
const emitProgress = async (message, metadata) => {
|
|
196
|
+
await this.createProgressEvent(job.id, message, metadata, notificationContext);
|
|
197
|
+
};
|
|
110
198
|
if (!courseId || !sessionId || !lessonId || !originalFileId || !(profiles === null || profiles === void 0 ? void 0 : profiles.length)) {
|
|
111
199
|
throw new Error('Invalid LMS video conversion payload');
|
|
112
200
|
}
|
|
@@ -117,19 +205,60 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
117
205
|
throw new Error('Course has no active profiles to process');
|
|
118
206
|
}
|
|
119
207
|
const maxInputBytes = this.getPositiveIntegerEnv('LMS_VIDEO_MAX_INPUT_BYTES');
|
|
120
|
-
const ffmpegTimeoutMs = (
|
|
208
|
+
const ffmpegTimeoutMs = (_c = this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS')) !== null && _c !== void 0 ? _c : 1000 * 60 * 60;
|
|
209
|
+
const intervalSettings = await this.settingService.getSettingValues([
|
|
210
|
+
'lms-video-frame-capture-interval-seconds',
|
|
211
|
+
'lms-image-extraction-enabled',
|
|
212
|
+
'lms-audio-transcription-enabled',
|
|
213
|
+
]);
|
|
214
|
+
const frameIntervalSeconds = this.resolveFrameIntervalSeconds(intervalSettings['lms-video-frame-capture-interval-seconds']);
|
|
215
|
+
const imageExtractionEnabled = intervalSettings['lms-image-extraction-enabled'] !== false;
|
|
216
|
+
const transcriptionEnabled = intervalSettings['lms-audio-transcription-enabled'] !== false;
|
|
121
217
|
const workDir = await fs_1.promises.mkdtemp((0, path_1.join)((0, os_1.tmpdir)(), `lms-video-${job.id}-`));
|
|
122
218
|
const inputPath = (0, path_1.join)(workDir, `original-${originalFileId}.mp4`);
|
|
123
219
|
this.logger.log(`Queue job ${job.id}: starting LMS video conversion (lesson=${lessonId}, profiles=${profilesToProcess.length})`);
|
|
220
|
+
await emitProgress('Baixando vídeo original...', {
|
|
221
|
+
phase: 'download_original',
|
|
222
|
+
lessonId,
|
|
223
|
+
});
|
|
224
|
+
this.logger.debug(`Queue job ${job.id}: payload resolved (course=${courseId}, session=${sessionId}, lesson=${lessonId}, originalFileId=${originalFileId}, overwrite=${overwrite !== false}, ffmpegTimeoutMs=${ffmpegTimeoutMs}, frameIntervalSeconds=${frameIntervalSeconds}, profiles=[${profilesToProcess.map((profile) => profile.id).join(', ')}])`);
|
|
124
225
|
const downloadedOriginal = await this.fileService.downloadToPath(originalFileId, inputPath, maxInputBytes ? { maxBytes: maxInputBytes } : undefined);
|
|
125
226
|
this.logger.log(`Queue job ${job.id}: original file downloaded (lesson=${lessonId}, size=${downloadedOriginal.size})`);
|
|
227
|
+
await emitProgress('Vídeo original baixado. Lendo duração...', {
|
|
228
|
+
phase: 'probe_duration',
|
|
229
|
+
lessonId,
|
|
230
|
+
});
|
|
231
|
+
let probedDuration = null;
|
|
232
|
+
try {
|
|
233
|
+
probedDuration = await this.probeDurationSeconds(inputPath, ffmpegTimeoutMs);
|
|
234
|
+
if (probedDuration !== null && probedDuration > 0) {
|
|
235
|
+
await this.prisma.course_lesson.update({
|
|
236
|
+
where: { id: lessonId },
|
|
237
|
+
data: { duration_seconds: probedDuration },
|
|
238
|
+
});
|
|
239
|
+
this.logger.log(`Queue job ${job.id}: probed video duration=${probedDuration}s, updated lesson ${lessonId}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
this.logger.warn(`Queue job ${job.id}: failed to probe duration for lesson ${lessonId}: ${(_d = err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
|
|
244
|
+
}
|
|
126
245
|
const jobStartedAt = Date.now();
|
|
127
246
|
const results = [];
|
|
247
|
+
let frameResults = [];
|
|
128
248
|
try {
|
|
129
249
|
for (const profile of profilesToProcess) {
|
|
130
250
|
const profileStartedAt = Date.now();
|
|
131
251
|
const outputPath = (0, path_1.join)(workDir, `${this.slugify(profile.name || String(profile.id))}-${profile.id}.mp4`);
|
|
132
252
|
this.logger.log(`Queue job ${job.id}: converting profile ${profile.id} for lesson ${lessonId}`);
|
|
253
|
+
await emitProgress(`Convertendo vídeo para o perfil ${profile.name || profile.id}...`, {
|
|
254
|
+
phase: 'convert_profile',
|
|
255
|
+
lessonId,
|
|
256
|
+
profileId: profile.id,
|
|
257
|
+
profileName: profile.name,
|
|
258
|
+
profileIndex: results.length + 1,
|
|
259
|
+
profileCount: profilesToProcess.length,
|
|
260
|
+
});
|
|
261
|
+
this.logger.debug(`Queue job ${job.id}: profile ${profile.id} ffmpeg params="${profile.ffmpeg_params}" outputPath=${outputPath}`);
|
|
133
262
|
const conversionStartedAt = Date.now();
|
|
134
263
|
await this.convert(inputPath, outputPath, profile.ffmpeg_params, ffmpegTimeoutMs);
|
|
135
264
|
const conversionMs = Date.now() - conversionStartedAt;
|
|
@@ -147,7 +276,7 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
147
276
|
lessonId,
|
|
148
277
|
type: this.profileResourceType(profile.id),
|
|
149
278
|
fileId: uploaded.id,
|
|
150
|
-
title: (
|
|
279
|
+
title: (_e = uploaded.filename) !== null && _e !== void 0 ? _e : (0, path_1.basename)(outputPath),
|
|
151
280
|
public: false,
|
|
152
281
|
overwrite: overwrite !== false,
|
|
153
282
|
});
|
|
@@ -163,6 +292,190 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
163
292
|
});
|
|
164
293
|
this.logger.log(`Queue job ${job.id}: profile ${profile.id} done for lesson ${lessonId} (fileId=${uploaded.id}, outputBytes=${outputStats.size}, conversionMs=${conversionMs}, conversionMBps=${conversionMBps.toFixed(2)}, uploadMs=${uploadMs}, uploadMBps=${uploadMBps.toFixed(2)}, totalMs=${totalMs})`);
|
|
165
294
|
}
|
|
295
|
+
if (imageExtractionEnabled) {
|
|
296
|
+
await emitProgress('Extraindo imagens do vídeo...', {
|
|
297
|
+
phase: 'extract_frames',
|
|
298
|
+
lessonId,
|
|
299
|
+
});
|
|
300
|
+
this.logger.debug(`Queue job ${job.id}: starting frame extraction for lesson ${lessonId}`);
|
|
301
|
+
frameResults = await this.extractAndUploadLessonFrames({
|
|
302
|
+
jobId: job.id,
|
|
303
|
+
lessonId,
|
|
304
|
+
inputPath,
|
|
305
|
+
workDir,
|
|
306
|
+
intervalSeconds: frameIntervalSeconds,
|
|
307
|
+
timeoutMs: ffmpegTimeoutMs,
|
|
308
|
+
});
|
|
309
|
+
this.logger.debug(`Queue job ${job.id}: frame extraction finished for lesson ${lessonId} (frames=${frameResults.length})`);
|
|
310
|
+
await emitProgress(frameResults.length > 0
|
|
311
|
+
? `Imagens extraídas com sucesso (${frameResults.length}).`
|
|
312
|
+
: 'Nenhuma imagem foi extraída do vídeo.', {
|
|
313
|
+
phase: 'extract_frames_done',
|
|
314
|
+
lessonId,
|
|
315
|
+
frames: frameResults.length,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
this.logger.debug(`Queue job ${job.id}: skipping frame extraction for lesson ${lessonId} (lms-image-extraction-enabled=false)`);
|
|
320
|
+
}
|
|
321
|
+
if (transcriptionEnabled) {
|
|
322
|
+
await emitProgress('Extraindo áudio do vídeo...', {
|
|
323
|
+
phase: 'extract_audio',
|
|
324
|
+
lessonId,
|
|
325
|
+
});
|
|
326
|
+
this.logger.debug(`Queue job ${job.id}: starting audio extraction for lesson ${lessonId}`);
|
|
327
|
+
const audioFileId = await this.extractAndUploadLessonAudio({
|
|
328
|
+
courseId,
|
|
329
|
+
lessonId,
|
|
330
|
+
localInputPath: inputPath,
|
|
331
|
+
tempDir: workDir,
|
|
332
|
+
});
|
|
333
|
+
this.logger.debug(`Queue job ${job.id}: audio extraction finished for lesson ${lessonId} (audioFileId=${audioFileId !== null && audioFileId !== void 0 ? audioFileId : 'null'})`);
|
|
334
|
+
if (audioFileId) {
|
|
335
|
+
await emitProgress('Áudio extraído. Agendando transcrição...', {
|
|
336
|
+
phase: 'queue_transcription',
|
|
337
|
+
lessonId,
|
|
338
|
+
audioFileId,
|
|
339
|
+
});
|
|
340
|
+
this.logger.debug(`Queue job ${job.id}: enqueueing transcription job for lesson ${lessonId} with audioFileId=${audioFileId}`);
|
|
341
|
+
const transcriptionJob = await this.queueJob.enqueue({
|
|
342
|
+
type: 'lms.audio.transcribe',
|
|
343
|
+
queueName: 'lms.audio.transcribe',
|
|
344
|
+
payload: {
|
|
345
|
+
courseId,
|
|
346
|
+
sessionId,
|
|
347
|
+
lessonId,
|
|
348
|
+
audioFileId,
|
|
349
|
+
parentJobId: job.id,
|
|
350
|
+
notificationId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.notificationId,
|
|
351
|
+
notificationUserId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.userId,
|
|
352
|
+
},
|
|
353
|
+
maxAttempts: 3,
|
|
354
|
+
});
|
|
355
|
+
await emitProgress(`Transcrição em IA agendada no job #${transcriptionJob.id}.`, {
|
|
356
|
+
phase: 'queue_transcription_done',
|
|
357
|
+
lessonId,
|
|
358
|
+
audioFileId,
|
|
359
|
+
transcriptionJobId: transcriptionJob.id,
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
lessonId,
|
|
363
|
+
probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
|
|
364
|
+
converted: results.length,
|
|
365
|
+
results,
|
|
366
|
+
extractedFrames: frameResults.length,
|
|
367
|
+
frames: frameResults,
|
|
368
|
+
transcriptionJobId: transcriptionJob.id,
|
|
369
|
+
metrics: {
|
|
370
|
+
totalMs: Date.now() - jobStartedAt,
|
|
371
|
+
totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
|
|
372
|
+
averageProfileMs: results.length > 0
|
|
373
|
+
? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
|
|
374
|
+
: 0,
|
|
375
|
+
averageConversionMBps: results.length > 0
|
|
376
|
+
? Number((results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length).toFixed(2))
|
|
377
|
+
: 0,
|
|
378
|
+
averageUploadMBps: results.length > 0
|
|
379
|
+
? Number((results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2))
|
|
380
|
+
: 0,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
let transcriptionSkipReason = 'audio extraction returned no file id';
|
|
386
|
+
try {
|
|
387
|
+
const existingAudio = await this.prisma.course_lesson_file.findFirst({
|
|
388
|
+
where: {
|
|
389
|
+
course_lesson_id: lessonId,
|
|
390
|
+
type: 'lesson_audio',
|
|
391
|
+
file_id: { not: null },
|
|
392
|
+
},
|
|
393
|
+
orderBy: { id: 'desc' },
|
|
394
|
+
select: { file_id: true },
|
|
395
|
+
});
|
|
396
|
+
const fallbackAudioFileId = Number((_f = existingAudio === null || existingAudio === void 0 ? void 0 : existingAudio.file_id) !== null && _f !== void 0 ? _f : 0);
|
|
397
|
+
if (fallbackAudioFileId > 0) {
|
|
398
|
+
transcriptionSkipReason =
|
|
399
|
+
'audio extraction returned no file id, using existing lesson_audio fallback';
|
|
400
|
+
this.logger.warn(`Queue job ${job.id}: audio extraction returned null for lesson ${lessonId}; enqueueing transcription with existing lesson_audio fileId=${fallbackAudioFileId}`);
|
|
401
|
+
await emitProgress('Áudio reutilizado de execução anterior. Agendando transcrição...', {
|
|
402
|
+
phase: 'queue_transcription',
|
|
403
|
+
lessonId,
|
|
404
|
+
audioFileId: fallbackAudioFileId,
|
|
405
|
+
fallback: true,
|
|
406
|
+
});
|
|
407
|
+
const transcriptionJob = await this.queueJob.enqueue({
|
|
408
|
+
type: 'lms.audio.transcribe',
|
|
409
|
+
queueName: 'lms.audio.transcribe',
|
|
410
|
+
payload: {
|
|
411
|
+
courseId,
|
|
412
|
+
sessionId,
|
|
413
|
+
lessonId,
|
|
414
|
+
audioFileId: fallbackAudioFileId,
|
|
415
|
+
parentJobId: job.id,
|
|
416
|
+
notificationId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.notificationId,
|
|
417
|
+
notificationUserId: notificationContext === null || notificationContext === void 0 ? void 0 : notificationContext.userId,
|
|
418
|
+
},
|
|
419
|
+
maxAttempts: 3,
|
|
420
|
+
});
|
|
421
|
+
await emitProgress(`Transcrição em IA agendada no job #${transcriptionJob.id}.`, {
|
|
422
|
+
phase: 'queue_transcription_done',
|
|
423
|
+
lessonId,
|
|
424
|
+
audioFileId: fallbackAudioFileId,
|
|
425
|
+
transcriptionJobId: transcriptionJob.id,
|
|
426
|
+
fallback: true,
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
lessonId,
|
|
430
|
+
probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
|
|
431
|
+
converted: results.length,
|
|
432
|
+
results,
|
|
433
|
+
extractedFrames: frameResults.length,
|
|
434
|
+
frames: frameResults,
|
|
435
|
+
transcriptionJobId: transcriptionJob.id,
|
|
436
|
+
metrics: {
|
|
437
|
+
totalMs: Date.now() - jobStartedAt,
|
|
438
|
+
totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
|
|
439
|
+
averageProfileMs: results.length > 0
|
|
440
|
+
? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
|
|
441
|
+
: 0,
|
|
442
|
+
averageConversionMBps: results.length > 0
|
|
443
|
+
? Number((results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length).toFixed(2))
|
|
444
|
+
: 0,
|
|
445
|
+
averageUploadMBps: results.length > 0
|
|
446
|
+
? Number((results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2))
|
|
447
|
+
: 0,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (fallbackError) {
|
|
453
|
+
transcriptionSkipReason = `fallback lookup failed: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`;
|
|
454
|
+
this.logger.warn(`Queue job ${job.id}: fallback lookup for lesson_audio failed on lesson ${lessonId}: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`);
|
|
455
|
+
}
|
|
456
|
+
this.logger.debug(`Queue job ${job.id}: skipping transcription enqueue for lesson ${lessonId} because no audio file was produced (${transcriptionSkipReason})`);
|
|
457
|
+
await emitProgress('Áudio não disponível. Encerrando pipeline sem transcrição.', {
|
|
458
|
+
phase: 'queue_transcription_skipped',
|
|
459
|
+
lessonId,
|
|
460
|
+
reason: transcriptionSkipReason,
|
|
461
|
+
});
|
|
462
|
+
if (notificationContext) {
|
|
463
|
+
await this.updateAsyncNotification(notificationContext, 100, 'Pipeline concluído sem transcrição: áudio indisponível para processamento.', true);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
this.logger.debug(`Queue job ${job.id}: skipping audio extraction and transcription for lesson ${lessonId} (lms-audio-transcription-enabled=false)`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (notificationContext) {
|
|
473
|
+
const message = error instanceof Error && error.message
|
|
474
|
+
? error.message
|
|
475
|
+
: 'Falha ao processar conversão de vídeo.';
|
|
476
|
+
await this.updateAsyncNotification(notificationContext, 100, `Falha no pipeline de vídeo: ${message}`, false);
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
166
479
|
}
|
|
167
480
|
finally {
|
|
168
481
|
await fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -171,10 +484,17 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
171
484
|
const totalMs = Date.now() - jobStartedAt;
|
|
172
485
|
const totalOutputBytes = results.reduce((acc, item) => acc + item.outputBytes, 0);
|
|
173
486
|
this.logger.log(`Queue job ${job.id}: LMS conversion finished (lesson=${lessonId}, profiles=${results.length}, totalMs=${totalMs}, totalOutputBytes=${totalOutputBytes})`);
|
|
487
|
+
if (notificationContext) {
|
|
488
|
+
await this.updateAsyncNotification(notificationContext, 100, 'Conversão e transcrição concluídas com sucesso.', true);
|
|
489
|
+
}
|
|
174
490
|
return {
|
|
175
491
|
lessonId,
|
|
492
|
+
probedDurationSeconds: probedDuration !== null && probedDuration !== void 0 ? probedDuration : null,
|
|
176
493
|
converted: results.length,
|
|
177
494
|
results,
|
|
495
|
+
extractedFrames: frameResults.length,
|
|
496
|
+
frames: frameResults,
|
|
497
|
+
transcriptionJobId: null,
|
|
178
498
|
metrics: {
|
|
179
499
|
totalMs,
|
|
180
500
|
totalOutputBytes,
|
|
@@ -190,6 +510,219 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
190
510
|
},
|
|
191
511
|
};
|
|
192
512
|
}
|
|
513
|
+
async extractAndUploadLessonFrames(params) {
|
|
514
|
+
const intervalSeconds = Math.max(1, Math.floor(params.intervalSeconds));
|
|
515
|
+
const framesDir = (0, path_1.join)(params.workDir, 'frames');
|
|
516
|
+
await fs_1.promises.mkdir(framesDir, { recursive: true });
|
|
517
|
+
const pattern = (0, path_1.join)(framesDir, 'frame-%08d.jpg');
|
|
518
|
+
this.logger.log(`Queue job ${params.jobId}: extracting lesson frames every ${intervalSeconds}s (lesson=${params.lessonId})`);
|
|
519
|
+
this.logger.debug(`Queue job ${params.jobId}: frame extraction input=${params.inputPath} outputPattern=${pattern} timeoutMs=${params.timeoutMs}`);
|
|
520
|
+
await this.extractFramesFromVideo(params.inputPath, pattern, intervalSeconds, params.timeoutMs);
|
|
521
|
+
const frameFiles = (await fs_1.promises.readdir(framesDir))
|
|
522
|
+
.filter((name) => name.toLowerCase().endsWith('.jpg'))
|
|
523
|
+
.sort((a, b) => a.localeCompare(b));
|
|
524
|
+
this.logger.debug(`Queue job ${params.jobId}: frame extraction produced ${frameFiles.length} file(s) in ${framesDir}`);
|
|
525
|
+
if (frameFiles.length === 0) {
|
|
526
|
+
this.logger.warn(`Queue job ${params.jobId}: no frames extracted for lesson ${params.lessonId}`);
|
|
527
|
+
await this.prisma.course_lesson_video_frame.deleteMany({
|
|
528
|
+
where: { course_lesson_id: params.lessonId },
|
|
529
|
+
});
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
const uploads = [];
|
|
533
|
+
for (let index = 0; index < frameFiles.length; index += 1) {
|
|
534
|
+
const fileName = frameFiles[index];
|
|
535
|
+
if (!fileName)
|
|
536
|
+
continue;
|
|
537
|
+
const framePath = (0, path_1.join)(framesDir, fileName);
|
|
538
|
+
const stats = await fs_1.promises.stat(framePath);
|
|
539
|
+
const uploaded = await this.fileService.uploadFromPath('lms/lessons/frames', framePath, {
|
|
540
|
+
originalname: fileName,
|
|
541
|
+
mimetype: 'image/jpeg',
|
|
542
|
+
});
|
|
543
|
+
uploads.push({
|
|
544
|
+
fileId: uploaded.id,
|
|
545
|
+
timeSeconds: index * intervalSeconds,
|
|
546
|
+
outputBytes: stats.size,
|
|
547
|
+
});
|
|
548
|
+
this.logger.debug(`Queue job ${params.jobId}: uploaded frame ${fileName} -> fileId=${uploaded.id}, timeSeconds=${index * intervalSeconds}, outputBytes=${stats.size}`);
|
|
549
|
+
}
|
|
550
|
+
await this.prisma.course_lesson_video_frame.deleteMany({
|
|
551
|
+
where: { course_lesson_id: params.lessonId },
|
|
552
|
+
});
|
|
553
|
+
await this.prisma.course_lesson_video_frame.createMany({
|
|
554
|
+
data: uploads.map((item) => ({
|
|
555
|
+
course_lesson_id: params.lessonId,
|
|
556
|
+
file_id: item.fileId,
|
|
557
|
+
time_seconds: item.timeSeconds,
|
|
558
|
+
})),
|
|
559
|
+
});
|
|
560
|
+
this.logger.log(`Queue job ${params.jobId}: extracted and uploaded ${uploads.length} lesson frames (lesson=${params.lessonId})`);
|
|
561
|
+
return uploads;
|
|
562
|
+
}
|
|
563
|
+
async extractAndUploadLessonAudio(params) {
|
|
564
|
+
var _a, _b, _c;
|
|
565
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
566
|
+
const mp3Path = (0, path_1.join)(params.tempDir, `lesson_${params.lessonId}_audio.mp3`);
|
|
567
|
+
const mp3LowBitratePath = (0, path_1.join)(params.tempDir, `lesson_${params.lessonId}_audio.low.mp3`);
|
|
568
|
+
let outputPath = mp3Path;
|
|
569
|
+
const mimetype = 'audio/mp3';
|
|
570
|
+
this.logger.debug(`Audio extraction: lesson=${params.lessonId}, input=${params.localInputPath}, preferredOutput=${mp3Path}`);
|
|
571
|
+
try {
|
|
572
|
+
await execFileAsync(ffmpegCommand, [
|
|
573
|
+
'-y',
|
|
574
|
+
'-i',
|
|
575
|
+
params.localInputPath,
|
|
576
|
+
'-vn',
|
|
577
|
+
'-ar',
|
|
578
|
+
'16000',
|
|
579
|
+
'-ac',
|
|
580
|
+
'1',
|
|
581
|
+
'-c:a',
|
|
582
|
+
'libmp3lame',
|
|
583
|
+
'-b:a',
|
|
584
|
+
'32k',
|
|
585
|
+
mp3Path,
|
|
586
|
+
], {
|
|
587
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
588
|
+
windowsHide: true,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
this.logger.warn(`Audio extraction failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
let uploadedAudioId = null;
|
|
596
|
+
try {
|
|
597
|
+
const course = await this.prisma.course.findUnique({
|
|
598
|
+
where: { id: params.courseId },
|
|
599
|
+
select: { locale_id: true },
|
|
600
|
+
});
|
|
601
|
+
let uploaded;
|
|
602
|
+
try {
|
|
603
|
+
uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
|
|
604
|
+
originalname: (0, path_1.basename)(outputPath),
|
|
605
|
+
mimetype,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (uploadError) {
|
|
609
|
+
// Retry with an even smaller bitrate when storage max size is too strict.
|
|
610
|
+
this.logger.warn(`Audio upload first attempt failed for lesson ${params.lessonId}, retrying with lower bitrate: ${uploadError instanceof Error ? uploadError.message : 'unknown error'}`);
|
|
611
|
+
outputPath = mp3LowBitratePath;
|
|
612
|
+
await execFileAsync(ffmpegCommand, [
|
|
613
|
+
'-y',
|
|
614
|
+
'-i',
|
|
615
|
+
params.localInputPath,
|
|
616
|
+
'-vn',
|
|
617
|
+
'-ar',
|
|
618
|
+
'16000',
|
|
619
|
+
'-ac',
|
|
620
|
+
'1',
|
|
621
|
+
'-c:a',
|
|
622
|
+
'libmp3lame',
|
|
623
|
+
'-b:a',
|
|
624
|
+
'16k',
|
|
625
|
+
mp3LowBitratePath,
|
|
626
|
+
], {
|
|
627
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
628
|
+
windowsHide: true,
|
|
629
|
+
});
|
|
630
|
+
uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
|
|
631
|
+
originalname: (0, path_1.basename)(outputPath),
|
|
632
|
+
mimetype,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
uploadedAudioId = uploaded.id;
|
|
636
|
+
this.logger.debug(`Audio extraction: lesson=${params.lessonId} uploaded audio fileId=${uploaded.id}, mimetype=${mimetype}, outputPath=${outputPath}`);
|
|
637
|
+
const existingAudio = await this.prisma.course_lesson_file.findFirst({
|
|
638
|
+
where: {
|
|
639
|
+
course_lesson_id: params.lessonId,
|
|
640
|
+
type: 'lesson_audio',
|
|
641
|
+
},
|
|
642
|
+
select: { id: true, locale_id: true },
|
|
643
|
+
});
|
|
644
|
+
const defaultLocale = await this.prisma.locale.findFirst({
|
|
645
|
+
where: {
|
|
646
|
+
OR: [{ code: 'pt-BR' }, { code: 'pt' }],
|
|
647
|
+
},
|
|
648
|
+
select: { id: true },
|
|
649
|
+
orderBy: { id: 'asc' },
|
|
650
|
+
});
|
|
651
|
+
const resolvedLocaleId = (_c = (_b = (_a = existingAudio === null || existingAudio === void 0 ? void 0 : existingAudio.locale_id) !== null && _a !== void 0 ? _a : course === null || course === void 0 ? void 0 : course.locale_id) !== null && _b !== void 0 ? _b : defaultLocale === null || defaultLocale === void 0 ? void 0 : defaultLocale.id) !== null && _c !== void 0 ? _c : null;
|
|
652
|
+
const audioTitle = 'Audio Original.mp3';
|
|
653
|
+
if (existingAudio) {
|
|
654
|
+
await this.prisma.course_lesson_file.update({
|
|
655
|
+
where: { id: existingAudio.id },
|
|
656
|
+
data: {
|
|
657
|
+
file_id: uploaded.id,
|
|
658
|
+
locale_id: resolvedLocaleId,
|
|
659
|
+
title: audioTitle,
|
|
660
|
+
type: 'lesson_audio',
|
|
661
|
+
is_public: false,
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
await this.prisma.course_lesson_file.create({
|
|
667
|
+
data: {
|
|
668
|
+
course_lesson_id: params.lessonId,
|
|
669
|
+
file_id: uploaded.id,
|
|
670
|
+
title: audioTitle,
|
|
671
|
+
type: 'lesson_audio',
|
|
672
|
+
is_public: false,
|
|
673
|
+
locale_id: resolvedLocaleId,
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return uploaded.id;
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (uploadedAudioId) {
|
|
681
|
+
this.logger.warn(`Audio metadata upsert failed for lesson ${params.lessonId}, but audio upload succeeded (fileId=${uploadedAudioId}): ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
682
|
+
return uploadedAudioId;
|
|
683
|
+
}
|
|
684
|
+
this.logger.warn(`Audio upload failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
this.logger.debug(`Audio extraction: lesson=${params.lessonId} cleaning temporary audio files (${mp3Path}, ${mp3LowBitratePath})`);
|
|
689
|
+
await fs_1.promises.rm(mp3Path, { force: true }).catch(() => undefined);
|
|
690
|
+
await fs_1.promises.rm(mp3LowBitratePath, { force: true }).catch(() => undefined);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async extractFramesFromVideo(inputPath, outputPattern, intervalSeconds, timeoutMs) {
|
|
694
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
695
|
+
const args = [
|
|
696
|
+
'-y',
|
|
697
|
+
'-i',
|
|
698
|
+
inputPath,
|
|
699
|
+
'-vf',
|
|
700
|
+
`fps=1/${intervalSeconds}`,
|
|
701
|
+
'-q:v',
|
|
702
|
+
'3',
|
|
703
|
+
'-qmin',
|
|
704
|
+
'2',
|
|
705
|
+
'-qmax',
|
|
706
|
+
'4',
|
|
707
|
+
outputPattern,
|
|
708
|
+
];
|
|
709
|
+
try {
|
|
710
|
+
await execFileAsync(ffmpegCommand, args, {
|
|
711
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
712
|
+
timeout: timeoutMs,
|
|
713
|
+
windowsHide: true,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
if ((error === null || error === void 0 ? void 0 : error.code) === 'ENOENT') {
|
|
718
|
+
throw new Error(`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`);
|
|
719
|
+
}
|
|
720
|
+
if ((error === null || error === void 0 ? void 0 : error.killed) && (error === null || error === void 0 ? void 0 : error.signal) === 'SIGTERM') {
|
|
721
|
+
throw new Error(`FFmpeg frame extraction timed out after ${timeoutMs}ms`);
|
|
722
|
+
}
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
193
726
|
async getCourseProfiles(courseId) {
|
|
194
727
|
const rows = await this.prisma.course_video_resolution_profile.findMany({
|
|
195
728
|
where: { course_id: courseId },
|
|
@@ -206,11 +739,12 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
206
739
|
}));
|
|
207
740
|
}
|
|
208
741
|
async upsertLessonFile(params) {
|
|
742
|
+
const normalizedType = this.normalizeLessonFileType(params.type);
|
|
209
743
|
if (params.overwrite) {
|
|
210
744
|
await this.prisma.course_lesson_file.deleteMany({
|
|
211
745
|
where: {
|
|
212
746
|
course_lesson_id: params.lessonId,
|
|
213
|
-
|
|
747
|
+
type: normalizedType,
|
|
214
748
|
},
|
|
215
749
|
});
|
|
216
750
|
}
|
|
@@ -219,27 +753,197 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
219
753
|
course_lesson_id: params.lessonId,
|
|
220
754
|
file_id: params.fileId,
|
|
221
755
|
title: params.title,
|
|
222
|
-
|
|
223
|
-
|
|
756
|
+
type: normalizedType,
|
|
757
|
+
is_public: params.public,
|
|
224
758
|
},
|
|
225
759
|
});
|
|
226
760
|
}
|
|
761
|
+
normalizeLessonFileType(value) {
|
|
762
|
+
if (value === 'video_original') {
|
|
763
|
+
return 'video_original';
|
|
764
|
+
}
|
|
765
|
+
if (value === null || value === void 0 ? void 0 : value.startsWith('video_profile:')) {
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
if (value === 'lesson_audio') {
|
|
769
|
+
return 'lesson_audio';
|
|
770
|
+
}
|
|
771
|
+
if (value === 'student_download' || value === 'file' || value === 'download') {
|
|
772
|
+
return 'student_download';
|
|
773
|
+
}
|
|
774
|
+
return 'supplementary_material';
|
|
775
|
+
}
|
|
227
776
|
async convert(inputPath, outputPath, params, timeoutMs) {
|
|
777
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
228
778
|
const args = ['-y', '-i', inputPath, ...this.splitFfmpegParams(params), outputPath];
|
|
229
779
|
try {
|
|
230
|
-
await execFileAsync(
|
|
780
|
+
await execFileAsync(ffmpegCommand, args, {
|
|
231
781
|
maxBuffer: 1024 * 1024 * 20,
|
|
232
782
|
timeout: timeoutMs,
|
|
233
783
|
windowsHide: true,
|
|
234
784
|
});
|
|
235
785
|
}
|
|
236
786
|
catch (error) {
|
|
787
|
+
if ((error === null || error === void 0 ? void 0 : error.code) === 'ENOENT') {
|
|
788
|
+
throw new Error(`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`);
|
|
789
|
+
}
|
|
237
790
|
if ((error === null || error === void 0 ? void 0 : error.killed) && (error === null || error === void 0 ? void 0 : error.signal) === 'SIGTERM') {
|
|
238
791
|
throw new Error(`FFmpeg timed out after ${timeoutMs}ms`);
|
|
239
792
|
}
|
|
240
793
|
throw error;
|
|
241
794
|
}
|
|
242
795
|
}
|
|
796
|
+
getFfmpegCommand() {
|
|
797
|
+
var _a;
|
|
798
|
+
const fromEnv = (_a = process.env.FFMPEG_PATH) === null || _a === void 0 ? void 0 : _a.trim();
|
|
799
|
+
if (fromEnv) {
|
|
800
|
+
return fromEnv;
|
|
801
|
+
}
|
|
802
|
+
if (process.platform === 'win32') {
|
|
803
|
+
const fromWinget = this.findWindowsWingetFfmpeg();
|
|
804
|
+
if (fromWinget) {
|
|
805
|
+
return fromWinget;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return 'ffmpeg';
|
|
809
|
+
}
|
|
810
|
+
getFfprobeCommand() {
|
|
811
|
+
var _a, _b;
|
|
812
|
+
const fromEnv = (_a = process.env.FFPROBE_PATH) === null || _a === void 0 ? void 0 : _a.trim();
|
|
813
|
+
if (fromEnv) {
|
|
814
|
+
return fromEnv;
|
|
815
|
+
}
|
|
816
|
+
const ffmpegEnv = (_b = process.env.FFMPEG_PATH) === null || _b === void 0 ? void 0 : _b.trim();
|
|
817
|
+
if (ffmpegEnv) {
|
|
818
|
+
const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext !== null && ext !== void 0 ? ext : ''}`);
|
|
819
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
820
|
+
return candidate;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (process.platform === 'win32') {
|
|
824
|
+
const fromWinget = this.findWindowsWingetFfprobe();
|
|
825
|
+
if (fromWinget) {
|
|
826
|
+
return fromWinget;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return 'ffprobe';
|
|
830
|
+
}
|
|
831
|
+
findWindowsWingetFfprobe() {
|
|
832
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
833
|
+
if (!localAppData) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
const wingetPackagesRoot = (0, path_1.join)(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
837
|
+
try {
|
|
838
|
+
const packageDirs = (0, fs_1.readdirSync)(wingetPackagesRoot, { withFileTypes: true })
|
|
839
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
|
|
840
|
+
.map((entry) => (0, path_1.join)(wingetPackagesRoot, entry.name))
|
|
841
|
+
.sort((a, b) => b.localeCompare(a));
|
|
842
|
+
for (const packageDir of packageDirs) {
|
|
843
|
+
const executable = this.findFfprobeExecutableInside(packageDir);
|
|
844
|
+
if (executable) {
|
|
845
|
+
return executable;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch (_a) {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
findFfprobeExecutableInside(baseDir) {
|
|
855
|
+
const directCandidate = (0, path_1.join)(baseDir, 'bin', 'ffprobe.exe');
|
|
856
|
+
if ((0, fs_1.existsSync)(directCandidate)) {
|
|
857
|
+
return directCandidate;
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const versionDirs = (0, fs_1.readdirSync)(baseDir, { withFileTypes: true })
|
|
861
|
+
.filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
|
|
862
|
+
.map((entry) => (0, path_1.join)(baseDir, entry.name))
|
|
863
|
+
.sort((a, b) => b.localeCompare(a));
|
|
864
|
+
for (const versionDir of versionDirs) {
|
|
865
|
+
const candidate = (0, path_1.join)(versionDir, 'bin', 'ffprobe.exe');
|
|
866
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
867
|
+
return candidate;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (_a) {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
async probeDurationSeconds(inputPath, timeoutMs) {
|
|
877
|
+
var _a;
|
|
878
|
+
const ffprobeCommand = this.getFfprobeCommand();
|
|
879
|
+
const args = [
|
|
880
|
+
'-v', 'error',
|
|
881
|
+
'-show_entries', 'format=duration',
|
|
882
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
883
|
+
inputPath,
|
|
884
|
+
];
|
|
885
|
+
try {
|
|
886
|
+
const { stdout } = await execFileAsync(ffprobeCommand, args, {
|
|
887
|
+
maxBuffer: 1024 * 1024,
|
|
888
|
+
timeout: Math.min(timeoutMs, 30000),
|
|
889
|
+
windowsHide: true,
|
|
890
|
+
});
|
|
891
|
+
const parsed = parseFloat(stdout.trim());
|
|
892
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
return Math.round(parsed);
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
this.logger.warn(`FFprobe duration extraction failed: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}`);
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
findWindowsWingetFfmpeg() {
|
|
903
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
904
|
+
if (!localAppData) {
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
const wingetPackagesRoot = (0, path_1.join)(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
908
|
+
try {
|
|
909
|
+
const packageDirs = (0, fs_1.readdirSync)(wingetPackagesRoot, { withFileTypes: true })
|
|
910
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
|
|
911
|
+
.map((entry) => (0, path_1.join)(wingetPackagesRoot, entry.name))
|
|
912
|
+
.sort((a, b) => b.localeCompare(a));
|
|
913
|
+
for (const packageDir of packageDirs) {
|
|
914
|
+
const executable = this.findFfmpegExecutableInside(packageDir);
|
|
915
|
+
if (executable) {
|
|
916
|
+
return executable;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch (_a) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
findFfmpegExecutableInside(baseDir) {
|
|
926
|
+
const directCandidate = (0, path_1.join)(baseDir, 'bin', 'ffmpeg.exe');
|
|
927
|
+
if ((0, fs_1.existsSync)(directCandidate)) {
|
|
928
|
+
return directCandidate;
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
const versionDirs = (0, fs_1.readdirSync)(baseDir, { withFileTypes: true })
|
|
932
|
+
.filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
|
|
933
|
+
.map((entry) => (0, path_1.join)(baseDir, entry.name))
|
|
934
|
+
.sort((a, b) => b.localeCompare(a));
|
|
935
|
+
for (const versionDir of versionDirs) {
|
|
936
|
+
const candidate = (0, path_1.join)(versionDir, 'bin', 'ffmpeg.exe');
|
|
937
|
+
if ((0, fs_1.existsSync)(candidate)) {
|
|
938
|
+
return candidate;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch (_a) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
243
947
|
getPositiveIntegerEnv(name) {
|
|
244
948
|
const raw = process.env[name];
|
|
245
949
|
if (!raw) {
|
|
@@ -251,6 +955,18 @@ let CourseVideoConversionService = CourseVideoConversionService_1 = class Course
|
|
|
251
955
|
}
|
|
252
956
|
return Math.floor(value);
|
|
253
957
|
}
|
|
958
|
+
resolveFrameIntervalSeconds(value) {
|
|
959
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
960
|
+
return Math.max(1, Math.floor(value));
|
|
961
|
+
}
|
|
962
|
+
if (typeof value === 'string') {
|
|
963
|
+
const parsed = Number(value);
|
|
964
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
965
|
+
return Math.max(1, Math.floor(parsed));
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return 10;
|
|
969
|
+
}
|
|
254
970
|
calculateMBps(bytes, durationMs) {
|
|
255
971
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
256
972
|
return 0;
|
|
@@ -298,10 +1014,14 @@ exports.CourseVideoConversionService = CourseVideoConversionService = CourseVide
|
|
|
298
1014
|
(0, common_1.Injectable)(),
|
|
299
1015
|
__param(0, (0, common_1.Inject)((0, common_1.forwardRef)(() => api_prisma_1.PrismaService))),
|
|
300
1016
|
__param(1, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.FileService))),
|
|
301
|
-
__param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() =>
|
|
302
|
-
__param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() =>
|
|
1017
|
+
__param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.SettingService))),
|
|
1018
|
+
__param(3, (0, common_1.Inject)((0, common_1.forwardRef)(() => core_1.NotificationService))),
|
|
1019
|
+
__param(4, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueHandlerRegistry))),
|
|
1020
|
+
__param(5, (0, common_1.Inject)((0, common_1.forwardRef)(() => queue_1.QueueJobService))),
|
|
303
1021
|
__metadata("design:paramtypes", [api_prisma_1.PrismaService,
|
|
304
1022
|
core_1.FileService,
|
|
1023
|
+
core_1.SettingService,
|
|
1024
|
+
core_1.NotificationService,
|
|
305
1025
|
queue_1.QueueHandlerRegistry,
|
|
306
1026
|
queue_1.QueueJobService])
|
|
307
1027
|
], CourseVideoConversionService);
|