@hed-hog/lms 0.0.353 → 0.0.355
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 +8 -8
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
-
import { FileService } from '@hed-hog/core';
|
|
2
|
+
import { FileService, NotificationService, SettingService } from '@hed-hog/core';
|
|
3
3
|
import { IJobHandler, QueueHandlerRegistry, QueueJobService } from '@hed-hog/queue';
|
|
4
4
|
import {
|
|
5
5
|
BadRequestException,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
forwardRef,
|
|
12
12
|
} from '@nestjs/common';
|
|
13
13
|
import { execFile } from 'child_process';
|
|
14
|
-
import { promises as fs } from 'fs';
|
|
14
|
+
import { existsSync, promises as fs, readdirSync } from 'fs';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
import { basename, join } from 'path';
|
|
17
17
|
import { promisify } from 'util';
|
|
@@ -24,17 +24,110 @@ type VideoProfilePayload = {
|
|
|
24
24
|
ffmpeg_params: string;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
type LessonFrameResult = {
|
|
28
|
+
fileId: number;
|
|
29
|
+
timeSeconds: number;
|
|
30
|
+
outputBytes: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
27
33
|
const execFileAsync = promisify(execFile);
|
|
28
34
|
|
|
35
|
+
type NotificationContext = {
|
|
36
|
+
userId: number;
|
|
37
|
+
notificationId: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
29
40
|
@Injectable()
|
|
30
41
|
export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
31
42
|
private readonly logger = new Logger(CourseVideoConversionService.name);
|
|
32
43
|
|
|
44
|
+
private async createProgressEvent(
|
|
45
|
+
queueJobId: number,
|
|
46
|
+
message: string,
|
|
47
|
+
metadata?: Record<string, unknown>,
|
|
48
|
+
notificationContext?: NotificationContext,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await (this.prisma as any).queue_job_event.create({
|
|
52
|
+
data: {
|
|
53
|
+
queue_job_id: queueJobId,
|
|
54
|
+
event_type: 'started',
|
|
55
|
+
message,
|
|
56
|
+
...(metadata ? { metadata } : {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.logger.warn(
|
|
61
|
+
`Queue job ${queueJobId}: failed to persist progress event "${message}": ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (notificationContext) {
|
|
66
|
+
const progress = this.resolveNotificationProgress(metadata);
|
|
67
|
+
await this.updateAsyncNotification(
|
|
68
|
+
notificationContext,
|
|
69
|
+
progress,
|
|
70
|
+
message,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private resolveNotificationProgress(metadata?: Record<string, unknown>): number {
|
|
76
|
+
const phase = String(metadata?.phase ?? '');
|
|
77
|
+
|
|
78
|
+
if (phase === 'download_original') return 5;
|
|
79
|
+
if (phase === 'probe_duration') return 10;
|
|
80
|
+
if (phase === 'convert_profile') {
|
|
81
|
+
const profileIndex = Number(metadata?.profileIndex ?? 0);
|
|
82
|
+
const profileCount = Number(metadata?.profileCount ?? 0);
|
|
83
|
+
if (profileIndex > 0 && profileCount > 0) {
|
|
84
|
+
const ratio = Math.min(1, Math.max(0, profileIndex / profileCount));
|
|
85
|
+
return 10 + Math.round(ratio * 45);
|
|
86
|
+
}
|
|
87
|
+
return 40;
|
|
88
|
+
}
|
|
89
|
+
if (phase === 'extract_frames') return 65;
|
|
90
|
+
if (phase === 'extract_frames_done') return 72;
|
|
91
|
+
if (phase === 'extract_audio') return 80;
|
|
92
|
+
if (phase === 'queue_transcription') return 88;
|
|
93
|
+
if (phase === 'queue_transcription_done') return 90;
|
|
94
|
+
if (phase === 'queue_transcription_skipped') return 100;
|
|
95
|
+
|
|
96
|
+
return 15;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async updateAsyncNotification(
|
|
100
|
+
context: NotificationContext,
|
|
101
|
+
progress: number,
|
|
102
|
+
body: string,
|
|
103
|
+
success?: boolean,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
try {
|
|
106
|
+
await this.notificationService.updateProgress(
|
|
107
|
+
context.userId,
|
|
108
|
+
context.notificationId,
|
|
109
|
+
{
|
|
110
|
+
progress,
|
|
111
|
+
body,
|
|
112
|
+
...(success != null ? { success } : {}),
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.logger.warn(
|
|
117
|
+
`Failed to update async notification ${context.notificationId} for user ${context.userId}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
33
122
|
constructor(
|
|
34
123
|
@Inject(forwardRef(() => PrismaService))
|
|
35
124
|
private readonly prisma: PrismaService,
|
|
36
125
|
@Inject(forwardRef(() => FileService))
|
|
37
126
|
private readonly fileService: FileService,
|
|
127
|
+
@Inject(forwardRef(() => SettingService))
|
|
128
|
+
private readonly settingService: SettingService,
|
|
129
|
+
@Inject(forwardRef(() => NotificationService))
|
|
130
|
+
private readonly notificationService: NotificationService,
|
|
38
131
|
@Inject(forwardRef(() => QueueHandlerRegistry))
|
|
39
132
|
private readonly registry: QueueHandlerRegistry,
|
|
40
133
|
@Inject(forwardRef(() => QueueJobService))
|
|
@@ -47,11 +140,21 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
47
140
|
}
|
|
48
141
|
|
|
49
142
|
async enqueueConversion(params: {
|
|
143
|
+
userId: number;
|
|
50
144
|
courseId: number;
|
|
51
145
|
sessionId: number;
|
|
52
146
|
lessonId: number;
|
|
53
147
|
originalFileId: number;
|
|
54
148
|
}) {
|
|
149
|
+
const settings = await this.settingService.getSettingValues(
|
|
150
|
+
'lms-video-conversion-enabled',
|
|
151
|
+
);
|
|
152
|
+
const isVideoConversionEnabled = settings['lms-video-conversion-enabled'] !== false;
|
|
153
|
+
|
|
154
|
+
if (!isVideoConversionEnabled) {
|
|
155
|
+
throw new BadRequestException('Video conversion queue is disabled by settings');
|
|
156
|
+
}
|
|
157
|
+
|
|
55
158
|
const lesson = await (this.prisma as any).course_lesson.findFirst({
|
|
56
159
|
where: {
|
|
57
160
|
id: params.lessonId,
|
|
@@ -102,6 +205,23 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
102
205
|
overwrite: true,
|
|
103
206
|
});
|
|
104
207
|
|
|
208
|
+
const asyncNotification = await this.notificationService.create({
|
|
209
|
+
user_id: params.userId,
|
|
210
|
+
title: 'Conversão e transcrição de vídeo em andamento',
|
|
211
|
+
body: 'Preparando pipeline de processamento do vídeo...',
|
|
212
|
+
type: 'progress' as any,
|
|
213
|
+
progress: 1,
|
|
214
|
+
started_at: new Date().toISOString(),
|
|
215
|
+
auto_remove: false,
|
|
216
|
+
action_type: 'url' as any,
|
|
217
|
+
action_url: '/queue/jobs',
|
|
218
|
+
action_data: {
|
|
219
|
+
source: 'lms-video-conversion',
|
|
220
|
+
courseId: params.courseId,
|
|
221
|
+
lessonId: params.lessonId,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
105
225
|
const job = await this.queueJob.enqueue({
|
|
106
226
|
type: LMS_VIDEO_CONVERSION_JOB,
|
|
107
227
|
queueName: LMS_VIDEO_CONVERSION_JOB,
|
|
@@ -112,6 +232,8 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
112
232
|
originalFileId: params.originalFileId,
|
|
113
233
|
profiles,
|
|
114
234
|
overwrite: true,
|
|
235
|
+
notificationId: asyncNotification.id,
|
|
236
|
+
notificationUserId: params.userId,
|
|
115
237
|
},
|
|
116
238
|
sourceModule: 'lms',
|
|
117
239
|
sourceEntity: 'course_lesson',
|
|
@@ -145,8 +267,26 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
145
267
|
originalFileId?: number;
|
|
146
268
|
profiles?: VideoProfilePayload[];
|
|
147
269
|
overwrite?: boolean;
|
|
270
|
+
notificationId?: number;
|
|
271
|
+
notificationUserId?: number;
|
|
148
272
|
};
|
|
149
273
|
|
|
274
|
+
const notificationContext: NotificationContext | undefined =
|
|
275
|
+
Number.isInteger(Number((job.payload as any)?.notificationId)) &&
|
|
276
|
+
Number.isInteger(Number((job.payload as any)?.notificationUserId))
|
|
277
|
+
? {
|
|
278
|
+
notificationId: Number((job.payload as any).notificationId),
|
|
279
|
+
userId: Number((job.payload as any).notificationUserId),
|
|
280
|
+
}
|
|
281
|
+
: undefined;
|
|
282
|
+
|
|
283
|
+
const emitProgress = async (
|
|
284
|
+
message: string,
|
|
285
|
+
metadata?: Record<string, unknown>,
|
|
286
|
+
) => {
|
|
287
|
+
await this.createProgressEvent(job.id, message, metadata, notificationContext);
|
|
288
|
+
};
|
|
289
|
+
|
|
150
290
|
if (!courseId || !sessionId || !lessonId || !originalFileId || !profiles?.length) {
|
|
151
291
|
throw new Error('Invalid LMS video conversion payload');
|
|
152
292
|
}
|
|
@@ -162,6 +302,16 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
162
302
|
const maxInputBytes = this.getPositiveIntegerEnv('LMS_VIDEO_MAX_INPUT_BYTES');
|
|
163
303
|
const ffmpegTimeoutMs =
|
|
164
304
|
this.getPositiveIntegerEnv('LMS_VIDEO_FFMPEG_TIMEOUT_MS') ?? 1000 * 60 * 60;
|
|
305
|
+
const intervalSettings = await this.settingService.getSettingValues([
|
|
306
|
+
'lms-video-frame-capture-interval-seconds',
|
|
307
|
+
'lms-image-extraction-enabled',
|
|
308
|
+
'lms-audio-transcription-enabled',
|
|
309
|
+
]);
|
|
310
|
+
const frameIntervalSeconds = this.resolveFrameIntervalSeconds(
|
|
311
|
+
intervalSettings['lms-video-frame-capture-interval-seconds'],
|
|
312
|
+
);
|
|
313
|
+
const imageExtractionEnabled = intervalSettings['lms-image-extraction-enabled'] !== false;
|
|
314
|
+
const transcriptionEnabled = intervalSettings['lms-audio-transcription-enabled'] !== false;
|
|
165
315
|
|
|
166
316
|
const workDir = await fs.mkdtemp(join(tmpdir(), `lms-video-${job.id}-`));
|
|
167
317
|
const inputPath = join(workDir, `original-${originalFileId}.mp4`);
|
|
@@ -169,6 +319,13 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
169
319
|
this.logger.log(
|
|
170
320
|
`Queue job ${job.id}: starting LMS video conversion (lesson=${lessonId}, profiles=${profilesToProcess.length})`,
|
|
171
321
|
);
|
|
322
|
+
await emitProgress('Baixando vídeo original...', {
|
|
323
|
+
phase: 'download_original',
|
|
324
|
+
lessonId,
|
|
325
|
+
});
|
|
326
|
+
this.logger.debug(
|
|
327
|
+
`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(', ')}])`,
|
|
328
|
+
);
|
|
172
329
|
|
|
173
330
|
const downloadedOriginal = await this.fileService.downloadToPath(
|
|
174
331
|
originalFileId,
|
|
@@ -179,6 +336,26 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
179
336
|
this.logger.log(
|
|
180
337
|
`Queue job ${job.id}: original file downloaded (lesson=${lessonId}, size=${downloadedOriginal.size})`,
|
|
181
338
|
);
|
|
339
|
+
await emitProgress('Vídeo original baixado. Lendo duração...', {
|
|
340
|
+
phase: 'probe_duration',
|
|
341
|
+
lessonId,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
let probedDuration: number | null = null;
|
|
345
|
+
try {
|
|
346
|
+
probedDuration = await this.probeDurationSeconds(inputPath, ffmpegTimeoutMs);
|
|
347
|
+
if (probedDuration !== null && probedDuration > 0) {
|
|
348
|
+
await (this.prisma as any).course_lesson.update({
|
|
349
|
+
where: { id: lessonId },
|
|
350
|
+
data: { duration_seconds: probedDuration },
|
|
351
|
+
});
|
|
352
|
+
this.logger.log(
|
|
353
|
+
`Queue job ${job.id}: probed video duration=${probedDuration}s, updated lesson ${lessonId}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
} catch (err: any) {
|
|
357
|
+
this.logger.warn(`Queue job ${job.id}: failed to probe duration for lesson ${lessonId}: ${err?.message ?? err}`);
|
|
358
|
+
}
|
|
182
359
|
|
|
183
360
|
const jobStartedAt = Date.now();
|
|
184
361
|
const results: Array<{
|
|
@@ -191,6 +368,7 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
191
368
|
uploadMBps: number;
|
|
192
369
|
totalMs: number;
|
|
193
370
|
}> = [];
|
|
371
|
+
let frameResults: LessonFrameResult[] = [];
|
|
194
372
|
|
|
195
373
|
try {
|
|
196
374
|
for (const profile of profilesToProcess) {
|
|
@@ -202,6 +380,20 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
202
380
|
this.logger.log(
|
|
203
381
|
`Queue job ${job.id}: converting profile ${profile.id} for lesson ${lessonId}`,
|
|
204
382
|
);
|
|
383
|
+
await emitProgress(
|
|
384
|
+
`Convertendo vídeo para o perfil ${profile.name || profile.id}...`,
|
|
385
|
+
{
|
|
386
|
+
phase: 'convert_profile',
|
|
387
|
+
lessonId,
|
|
388
|
+
profileId: profile.id,
|
|
389
|
+
profileName: profile.name,
|
|
390
|
+
profileIndex: results.length + 1,
|
|
391
|
+
profileCount: profilesToProcess.length,
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
this.logger.debug(
|
|
395
|
+
`Queue job ${job.id}: profile ${profile.id} ffmpeg params="${profile.ffmpeg_params}" outputPath=${outputPath}`,
|
|
396
|
+
);
|
|
205
397
|
|
|
206
398
|
const conversionStartedAt = Date.now();
|
|
207
399
|
await this.convert(inputPath, outputPath, profile.ffmpeg_params, ffmpegTimeoutMs);
|
|
@@ -243,6 +435,259 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
243
435
|
`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})`,
|
|
244
436
|
);
|
|
245
437
|
}
|
|
438
|
+
|
|
439
|
+
if (imageExtractionEnabled) {
|
|
440
|
+
await emitProgress('Extraindo imagens do vídeo...', {
|
|
441
|
+
phase: 'extract_frames',
|
|
442
|
+
lessonId,
|
|
443
|
+
});
|
|
444
|
+
this.logger.debug(
|
|
445
|
+
`Queue job ${job.id}: starting frame extraction for lesson ${lessonId}`,
|
|
446
|
+
);
|
|
447
|
+
frameResults = await this.extractAndUploadLessonFrames({
|
|
448
|
+
jobId: job.id,
|
|
449
|
+
lessonId,
|
|
450
|
+
inputPath,
|
|
451
|
+
workDir,
|
|
452
|
+
intervalSeconds: frameIntervalSeconds,
|
|
453
|
+
timeoutMs: ffmpegTimeoutMs,
|
|
454
|
+
});
|
|
455
|
+
this.logger.debug(
|
|
456
|
+
`Queue job ${job.id}: frame extraction finished for lesson ${lessonId} (frames=${frameResults.length})`,
|
|
457
|
+
);
|
|
458
|
+
await emitProgress(
|
|
459
|
+
frameResults.length > 0
|
|
460
|
+
? `Imagens extraídas com sucesso (${frameResults.length}).`
|
|
461
|
+
: 'Nenhuma imagem foi extraída do vídeo.',
|
|
462
|
+
{
|
|
463
|
+
phase: 'extract_frames_done',
|
|
464
|
+
lessonId,
|
|
465
|
+
frames: frameResults.length,
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
} else {
|
|
469
|
+
this.logger.debug(
|
|
470
|
+
`Queue job ${job.id}: skipping frame extraction for lesson ${lessonId} (lms-image-extraction-enabled=false)`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (transcriptionEnabled) {
|
|
475
|
+
await emitProgress('Extraindo áudio do vídeo...', {
|
|
476
|
+
phase: 'extract_audio',
|
|
477
|
+
lessonId,
|
|
478
|
+
});
|
|
479
|
+
this.logger.debug(
|
|
480
|
+
`Queue job ${job.id}: starting audio extraction for lesson ${lessonId}`,
|
|
481
|
+
);
|
|
482
|
+
const audioFileId = await this.extractAndUploadLessonAudio({
|
|
483
|
+
courseId,
|
|
484
|
+
lessonId,
|
|
485
|
+
localInputPath: inputPath,
|
|
486
|
+
tempDir: workDir,
|
|
487
|
+
});
|
|
488
|
+
this.logger.debug(
|
|
489
|
+
`Queue job ${job.id}: audio extraction finished for lesson ${lessonId} (audioFileId=${audioFileId ?? 'null'})`,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
if (audioFileId) {
|
|
493
|
+
await emitProgress('Áudio extraído. Agendando transcrição...', {
|
|
494
|
+
phase: 'queue_transcription',
|
|
495
|
+
lessonId,
|
|
496
|
+
audioFileId,
|
|
497
|
+
});
|
|
498
|
+
this.logger.debug(
|
|
499
|
+
`Queue job ${job.id}: enqueueing transcription job for lesson ${lessonId} with audioFileId=${audioFileId}`,
|
|
500
|
+
);
|
|
501
|
+
const transcriptionJob = await this.queueJob.enqueue({
|
|
502
|
+
type: 'lms.audio.transcribe',
|
|
503
|
+
queueName: 'lms.audio.transcribe',
|
|
504
|
+
payload: {
|
|
505
|
+
courseId,
|
|
506
|
+
sessionId,
|
|
507
|
+
lessonId,
|
|
508
|
+
audioFileId,
|
|
509
|
+
parentJobId: job.id,
|
|
510
|
+
notificationId: notificationContext?.notificationId,
|
|
511
|
+
notificationUserId: notificationContext?.userId,
|
|
512
|
+
},
|
|
513
|
+
maxAttempts: 3,
|
|
514
|
+
});
|
|
515
|
+
await emitProgress(
|
|
516
|
+
`Transcrição em IA agendada no job #${transcriptionJob.id}.`,
|
|
517
|
+
{
|
|
518
|
+
phase: 'queue_transcription_done',
|
|
519
|
+
lessonId,
|
|
520
|
+
audioFileId,
|
|
521
|
+
transcriptionJobId: transcriptionJob.id,
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
return {
|
|
525
|
+
lessonId,
|
|
526
|
+
probedDurationSeconds: probedDuration ?? null,
|
|
527
|
+
converted: results.length,
|
|
528
|
+
results,
|
|
529
|
+
extractedFrames: frameResults.length,
|
|
530
|
+
frames: frameResults,
|
|
531
|
+
transcriptionJobId: transcriptionJob.id,
|
|
532
|
+
metrics: {
|
|
533
|
+
totalMs: Date.now() - jobStartedAt,
|
|
534
|
+
totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
|
|
535
|
+
averageProfileMs:
|
|
536
|
+
results.length > 0
|
|
537
|
+
? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
|
|
538
|
+
: 0,
|
|
539
|
+
averageConversionMBps:
|
|
540
|
+
results.length > 0
|
|
541
|
+
? Number(
|
|
542
|
+
(
|
|
543
|
+
results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length
|
|
544
|
+
).toFixed(2),
|
|
545
|
+
)
|
|
546
|
+
: 0,
|
|
547
|
+
averageUploadMBps:
|
|
548
|
+
results.length > 0
|
|
549
|
+
? Number(
|
|
550
|
+
(results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2),
|
|
551
|
+
)
|
|
552
|
+
: 0,
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
} else {
|
|
556
|
+
let transcriptionSkipReason = 'audio extraction returned no file id';
|
|
557
|
+
try {
|
|
558
|
+
const existingAudio = await (this.prisma as any).course_lesson_file.findFirst({
|
|
559
|
+
where: {
|
|
560
|
+
course_lesson_id: lessonId,
|
|
561
|
+
type: 'lesson_audio',
|
|
562
|
+
file_id: { not: null },
|
|
563
|
+
},
|
|
564
|
+
orderBy: { id: 'desc' },
|
|
565
|
+
select: { file_id: true },
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const fallbackAudioFileId = Number(existingAudio?.file_id ?? 0);
|
|
569
|
+
if (fallbackAudioFileId > 0) {
|
|
570
|
+
transcriptionSkipReason =
|
|
571
|
+
'audio extraction returned no file id, using existing lesson_audio fallback';
|
|
572
|
+
this.logger.warn(
|
|
573
|
+
`Queue job ${job.id}: audio extraction returned null for lesson ${lessonId}; enqueueing transcription with existing lesson_audio fileId=${fallbackAudioFileId}`,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
await emitProgress(
|
|
577
|
+
'Áudio reutilizado de execução anterior. Agendando transcrição...',
|
|
578
|
+
{
|
|
579
|
+
phase: 'queue_transcription',
|
|
580
|
+
lessonId,
|
|
581
|
+
audioFileId: fallbackAudioFileId,
|
|
582
|
+
fallback: true,
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const transcriptionJob = await this.queueJob.enqueue({
|
|
587
|
+
type: 'lms.audio.transcribe',
|
|
588
|
+
queueName: 'lms.audio.transcribe',
|
|
589
|
+
payload: {
|
|
590
|
+
courseId,
|
|
591
|
+
sessionId,
|
|
592
|
+
lessonId,
|
|
593
|
+
audioFileId: fallbackAudioFileId,
|
|
594
|
+
parentJobId: job.id,
|
|
595
|
+
notificationId: notificationContext?.notificationId,
|
|
596
|
+
notificationUserId: notificationContext?.userId,
|
|
597
|
+
},
|
|
598
|
+
maxAttempts: 3,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await emitProgress(
|
|
602
|
+
`Transcrição em IA agendada no job #${transcriptionJob.id}.`,
|
|
603
|
+
{
|
|
604
|
+
phase: 'queue_transcription_done',
|
|
605
|
+
lessonId,
|
|
606
|
+
audioFileId: fallbackAudioFileId,
|
|
607
|
+
transcriptionJobId: transcriptionJob.id,
|
|
608
|
+
fallback: true,
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
lessonId,
|
|
614
|
+
probedDurationSeconds: probedDuration ?? null,
|
|
615
|
+
converted: results.length,
|
|
616
|
+
results,
|
|
617
|
+
extractedFrames: frameResults.length,
|
|
618
|
+
frames: frameResults,
|
|
619
|
+
transcriptionJobId: transcriptionJob.id,
|
|
620
|
+
metrics: {
|
|
621
|
+
totalMs: Date.now() - jobStartedAt,
|
|
622
|
+
totalOutputBytes: results.reduce((acc, item) => acc + item.outputBytes, 0),
|
|
623
|
+
averageProfileMs:
|
|
624
|
+
results.length > 0
|
|
625
|
+
? Math.round(results.reduce((acc, item) => acc + item.totalMs, 0) / results.length)
|
|
626
|
+
: 0,
|
|
627
|
+
averageConversionMBps:
|
|
628
|
+
results.length > 0
|
|
629
|
+
? Number(
|
|
630
|
+
(
|
|
631
|
+
results.reduce((acc, item) => acc + item.conversionMBps, 0) / results.length
|
|
632
|
+
).toFixed(2),
|
|
633
|
+
)
|
|
634
|
+
: 0,
|
|
635
|
+
averageUploadMBps:
|
|
636
|
+
results.length > 0
|
|
637
|
+
? Number(
|
|
638
|
+
(results.reduce((acc, item) => acc + item.uploadMBps, 0) / results.length).toFixed(2),
|
|
639
|
+
)
|
|
640
|
+
: 0,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
} catch (fallbackError) {
|
|
645
|
+
transcriptionSkipReason = `fallback lookup failed: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`;
|
|
646
|
+
this.logger.warn(
|
|
647
|
+
`Queue job ${job.id}: fallback lookup for lesson_audio failed on lesson ${lessonId}: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.logger.debug(
|
|
652
|
+
`Queue job ${job.id}: skipping transcription enqueue for lesson ${lessonId} because no audio file was produced (${transcriptionSkipReason})`,
|
|
653
|
+
);
|
|
654
|
+
await emitProgress(
|
|
655
|
+
'Áudio não disponível. Encerrando pipeline sem transcrição.',
|
|
656
|
+
{
|
|
657
|
+
phase: 'queue_transcription_skipped',
|
|
658
|
+
lessonId,
|
|
659
|
+
reason: transcriptionSkipReason,
|
|
660
|
+
},
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
if (notificationContext) {
|
|
664
|
+
await this.updateAsyncNotification(
|
|
665
|
+
notificationContext,
|
|
666
|
+
100,
|
|
667
|
+
'Pipeline concluído sem transcrição: áudio indisponível para processamento.',
|
|
668
|
+
true,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
this.logger.debug(
|
|
674
|
+
`Queue job ${job.id}: skipping audio extraction and transcription for lesson ${lessonId} (lms-audio-transcription-enabled=false)`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (notificationContext) {
|
|
679
|
+
const message =
|
|
680
|
+
error instanceof Error && error.message
|
|
681
|
+
? error.message
|
|
682
|
+
: 'Falha ao processar conversão de vídeo.';
|
|
683
|
+
await this.updateAsyncNotification(
|
|
684
|
+
notificationContext,
|
|
685
|
+
100,
|
|
686
|
+
`Falha no pipeline de vídeo: ${message}`,
|
|
687
|
+
false,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
throw error;
|
|
246
691
|
} finally {
|
|
247
692
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
248
693
|
this.logger.log(`Queue job ${job.id}: cleaned temporary folder ${workDir}`);
|
|
@@ -255,10 +700,23 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
255
700
|
`Queue job ${job.id}: LMS conversion finished (lesson=${lessonId}, profiles=${results.length}, totalMs=${totalMs}, totalOutputBytes=${totalOutputBytes})`,
|
|
256
701
|
);
|
|
257
702
|
|
|
703
|
+
if (notificationContext) {
|
|
704
|
+
await this.updateAsyncNotification(
|
|
705
|
+
notificationContext,
|
|
706
|
+
100,
|
|
707
|
+
'Conversão e transcrição concluídas com sucesso.',
|
|
708
|
+
true,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
258
712
|
return {
|
|
259
713
|
lessonId,
|
|
714
|
+
probedDurationSeconds: probedDuration ?? null,
|
|
260
715
|
converted: results.length,
|
|
261
716
|
results,
|
|
717
|
+
extractedFrames: frameResults.length,
|
|
718
|
+
frames: frameResults,
|
|
719
|
+
transcriptionJobId: null,
|
|
262
720
|
metrics: {
|
|
263
721
|
totalMs,
|
|
264
722
|
totalOutputBytes,
|
|
@@ -284,6 +742,301 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
284
742
|
};
|
|
285
743
|
}
|
|
286
744
|
|
|
745
|
+
private async extractAndUploadLessonFrames(params: {
|
|
746
|
+
jobId: number;
|
|
747
|
+
lessonId: number;
|
|
748
|
+
inputPath: string;
|
|
749
|
+
workDir: string;
|
|
750
|
+
intervalSeconds: number;
|
|
751
|
+
timeoutMs: number;
|
|
752
|
+
}): Promise<LessonFrameResult[]> {
|
|
753
|
+
const intervalSeconds = Math.max(1, Math.floor(params.intervalSeconds));
|
|
754
|
+
const framesDir = join(params.workDir, 'frames');
|
|
755
|
+
await fs.mkdir(framesDir, { recursive: true });
|
|
756
|
+
|
|
757
|
+
const pattern = join(framesDir, 'frame-%08d.jpg');
|
|
758
|
+
|
|
759
|
+
this.logger.log(
|
|
760
|
+
`Queue job ${params.jobId}: extracting lesson frames every ${intervalSeconds}s (lesson=${params.lessonId})`,
|
|
761
|
+
);
|
|
762
|
+
this.logger.debug(
|
|
763
|
+
`Queue job ${params.jobId}: frame extraction input=${params.inputPath} outputPattern=${pattern} timeoutMs=${params.timeoutMs}`,
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
await this.extractFramesFromVideo(
|
|
767
|
+
params.inputPath,
|
|
768
|
+
pattern,
|
|
769
|
+
intervalSeconds,
|
|
770
|
+
params.timeoutMs,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const frameFiles = (await fs.readdir(framesDir))
|
|
774
|
+
.filter((name) => name.toLowerCase().endsWith('.jpg'))
|
|
775
|
+
.sort((a, b) => a.localeCompare(b));
|
|
776
|
+
this.logger.debug(
|
|
777
|
+
`Queue job ${params.jobId}: frame extraction produced ${frameFiles.length} file(s) in ${framesDir}`,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
if (frameFiles.length === 0) {
|
|
781
|
+
this.logger.warn(
|
|
782
|
+
`Queue job ${params.jobId}: no frames extracted for lesson ${params.lessonId}`,
|
|
783
|
+
);
|
|
784
|
+
await (this.prisma as any).course_lesson_video_frame.deleteMany({
|
|
785
|
+
where: { course_lesson_id: params.lessonId },
|
|
786
|
+
});
|
|
787
|
+
return [];
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const uploads: LessonFrameResult[] = [];
|
|
791
|
+
for (let index = 0; index < frameFiles.length; index += 1) {
|
|
792
|
+
const fileName = frameFiles[index];
|
|
793
|
+
if (!fileName) continue;
|
|
794
|
+
|
|
795
|
+
const framePath = join(framesDir, fileName);
|
|
796
|
+
const stats = await fs.stat(framePath);
|
|
797
|
+
const uploaded = await this.fileService.uploadFromPath('lms/lessons/frames', framePath, {
|
|
798
|
+
originalname: fileName,
|
|
799
|
+
mimetype: 'image/jpeg',
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
uploads.push({
|
|
803
|
+
fileId: uploaded.id,
|
|
804
|
+
timeSeconds: index * intervalSeconds,
|
|
805
|
+
outputBytes: stats.size,
|
|
806
|
+
});
|
|
807
|
+
this.logger.debug(
|
|
808
|
+
`Queue job ${params.jobId}: uploaded frame ${fileName} -> fileId=${uploaded.id}, timeSeconds=${index * intervalSeconds}, outputBytes=${stats.size}`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
await (this.prisma as any).course_lesson_video_frame.deleteMany({
|
|
813
|
+
where: { course_lesson_id: params.lessonId },
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
await (this.prisma as any).course_lesson_video_frame.createMany({
|
|
817
|
+
data: uploads.map((item) => ({
|
|
818
|
+
course_lesson_id: params.lessonId,
|
|
819
|
+
file_id: item.fileId,
|
|
820
|
+
time_seconds: item.timeSeconds,
|
|
821
|
+
})),
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
this.logger.log(
|
|
825
|
+
`Queue job ${params.jobId}: extracted and uploaded ${uploads.length} lesson frames (lesson=${params.lessonId})`,
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
return uploads;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private async extractAndUploadLessonAudio(params: {
|
|
832
|
+
courseId: number;
|
|
833
|
+
lessonId: number;
|
|
834
|
+
localInputPath: string;
|
|
835
|
+
tempDir: string;
|
|
836
|
+
}): Promise<number | null> {
|
|
837
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
838
|
+
const mp3Path = join(params.tempDir, `lesson_${params.lessonId}_audio.mp3`);
|
|
839
|
+
const mp3LowBitratePath = join(
|
|
840
|
+
params.tempDir,
|
|
841
|
+
`lesson_${params.lessonId}_audio.low.mp3`,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
let outputPath = mp3Path;
|
|
845
|
+
const mimetype = 'audio/mp3';
|
|
846
|
+
|
|
847
|
+
this.logger.debug(
|
|
848
|
+
`Audio extraction: lesson=${params.lessonId}, input=${params.localInputPath}, preferredOutput=${mp3Path}`,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
await execFileAsync(
|
|
853
|
+
ffmpegCommand,
|
|
854
|
+
[
|
|
855
|
+
'-y',
|
|
856
|
+
'-i',
|
|
857
|
+
params.localInputPath,
|
|
858
|
+
'-vn',
|
|
859
|
+
'-ar',
|
|
860
|
+
'16000',
|
|
861
|
+
'-ac',
|
|
862
|
+
'1',
|
|
863
|
+
'-c:a',
|
|
864
|
+
'libmp3lame',
|
|
865
|
+
'-b:a',
|
|
866
|
+
'32k',
|
|
867
|
+
mp3Path,
|
|
868
|
+
],
|
|
869
|
+
{
|
|
870
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
871
|
+
windowsHide: true,
|
|
872
|
+
},
|
|
873
|
+
);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
this.logger.warn(
|
|
876
|
+
`Audio extraction failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
877
|
+
);
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
let uploadedAudioId: number | null = null;
|
|
882
|
+
try {
|
|
883
|
+
const course = await (this.prisma as any).course.findUnique({
|
|
884
|
+
where: { id: params.courseId },
|
|
885
|
+
select: { locale_id: true },
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
let uploaded;
|
|
889
|
+
try {
|
|
890
|
+
uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
|
|
891
|
+
originalname: basename(outputPath),
|
|
892
|
+
mimetype,
|
|
893
|
+
});
|
|
894
|
+
} catch (uploadError) {
|
|
895
|
+
// Retry with an even smaller bitrate when storage max size is too strict.
|
|
896
|
+
this.logger.warn(
|
|
897
|
+
`Audio upload first attempt failed for lesson ${params.lessonId}, retrying with lower bitrate: ${uploadError instanceof Error ? uploadError.message : 'unknown error'}`,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
outputPath = mp3LowBitratePath;
|
|
901
|
+
await execFileAsync(
|
|
902
|
+
ffmpegCommand,
|
|
903
|
+
[
|
|
904
|
+
'-y',
|
|
905
|
+
'-i',
|
|
906
|
+
params.localInputPath,
|
|
907
|
+
'-vn',
|
|
908
|
+
'-ar',
|
|
909
|
+
'16000',
|
|
910
|
+
'-ac',
|
|
911
|
+
'1',
|
|
912
|
+
'-c:a',
|
|
913
|
+
'libmp3lame',
|
|
914
|
+
'-b:a',
|
|
915
|
+
'16k',
|
|
916
|
+
mp3LowBitratePath,
|
|
917
|
+
],
|
|
918
|
+
{
|
|
919
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
920
|
+
windowsHide: true,
|
|
921
|
+
},
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
uploaded = await this.fileService.uploadFromPath('lms/lessons/audio', outputPath, {
|
|
925
|
+
originalname: basename(outputPath),
|
|
926
|
+
mimetype,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
uploadedAudioId = uploaded.id;
|
|
930
|
+
this.logger.debug(
|
|
931
|
+
`Audio extraction: lesson=${params.lessonId} uploaded audio fileId=${uploaded.id}, mimetype=${mimetype}, outputPath=${outputPath}`,
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
const existingAudio = await (this.prisma as any).course_lesson_file.findFirst({
|
|
935
|
+
where: {
|
|
936
|
+
course_lesson_id: params.lessonId,
|
|
937
|
+
type: 'lesson_audio',
|
|
938
|
+
},
|
|
939
|
+
select: { id: true, locale_id: true },
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const defaultLocale = await (this.prisma as any).locale.findFirst({
|
|
943
|
+
where: {
|
|
944
|
+
OR: [{ code: 'pt-BR' }, { code: 'pt' }],
|
|
945
|
+
},
|
|
946
|
+
select: { id: true },
|
|
947
|
+
orderBy: { id: 'asc' },
|
|
948
|
+
});
|
|
949
|
+
const resolvedLocaleId =
|
|
950
|
+
existingAudio?.locale_id ?? course?.locale_id ?? defaultLocale?.id ?? null;
|
|
951
|
+
const audioTitle = 'Audio Original.mp3';
|
|
952
|
+
|
|
953
|
+
if (existingAudio) {
|
|
954
|
+
await (this.prisma as any).course_lesson_file.update({
|
|
955
|
+
where: { id: existingAudio.id },
|
|
956
|
+
data: {
|
|
957
|
+
file_id: uploaded.id,
|
|
958
|
+
locale_id: resolvedLocaleId,
|
|
959
|
+
title: audioTitle,
|
|
960
|
+
type: 'lesson_audio',
|
|
961
|
+
is_public: false,
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
} else {
|
|
965
|
+
await (this.prisma as any).course_lesson_file.create({
|
|
966
|
+
data: {
|
|
967
|
+
course_lesson_id: params.lessonId,
|
|
968
|
+
file_id: uploaded.id,
|
|
969
|
+
title: audioTitle,
|
|
970
|
+
type: 'lesson_audio',
|
|
971
|
+
is_public: false,
|
|
972
|
+
locale_id: resolvedLocaleId,
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return uploaded.id;
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (uploadedAudioId) {
|
|
980
|
+
this.logger.warn(
|
|
981
|
+
`Audio metadata upsert failed for lesson ${params.lessonId}, but audio upload succeeded (fileId=${uploadedAudioId}): ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
982
|
+
);
|
|
983
|
+
return uploadedAudioId;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
this.logger.warn(
|
|
987
|
+
`Audio upload failed for lesson ${params.lessonId}: ${error instanceof Error ? error.message : 'unknown error'}`,
|
|
988
|
+
);
|
|
989
|
+
return null;
|
|
990
|
+
} finally {
|
|
991
|
+
this.logger.debug(
|
|
992
|
+
`Audio extraction: lesson=${params.lessonId} cleaning temporary audio files (${mp3Path}, ${mp3LowBitratePath})`,
|
|
993
|
+
);
|
|
994
|
+
await fs.rm(mp3Path, { force: true }).catch(() => undefined);
|
|
995
|
+
await fs.rm(mp3LowBitratePath, { force: true }).catch(() => undefined);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private async extractFramesFromVideo(
|
|
1000
|
+
inputPath: string,
|
|
1001
|
+
outputPattern: string,
|
|
1002
|
+
intervalSeconds: number,
|
|
1003
|
+
timeoutMs: number,
|
|
1004
|
+
) {
|
|
1005
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
1006
|
+
const args = [
|
|
1007
|
+
'-y',
|
|
1008
|
+
'-i',
|
|
1009
|
+
inputPath,
|
|
1010
|
+
'-vf',
|
|
1011
|
+
`fps=1/${intervalSeconds}`,
|
|
1012
|
+
'-q:v',
|
|
1013
|
+
'3',
|
|
1014
|
+
'-qmin',
|
|
1015
|
+
'2',
|
|
1016
|
+
'-qmax',
|
|
1017
|
+
'4',
|
|
1018
|
+
outputPattern,
|
|
1019
|
+
];
|
|
1020
|
+
|
|
1021
|
+
try {
|
|
1022
|
+
await execFileAsync(ffmpegCommand, args, {
|
|
1023
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
1024
|
+
timeout: timeoutMs,
|
|
1025
|
+
windowsHide: true,
|
|
1026
|
+
});
|
|
1027
|
+
} catch (error: any) {
|
|
1028
|
+
if (error?.code === 'ENOENT') {
|
|
1029
|
+
throw new Error(
|
|
1030
|
+
`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (error?.killed && error?.signal === 'SIGTERM') {
|
|
1034
|
+
throw new Error(`FFmpeg frame extraction timed out after ${timeoutMs}ms`);
|
|
1035
|
+
}
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
287
1040
|
private async getCourseProfiles(courseId: number): Promise<VideoProfilePayload[]> {
|
|
288
1041
|
const rows = await (this.prisma as any).course_video_resolution_profile.findMany({
|
|
289
1042
|
where: { course_id: courseId },
|
|
@@ -309,11 +1062,13 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
309
1062
|
public: boolean;
|
|
310
1063
|
overwrite: boolean;
|
|
311
1064
|
}) {
|
|
1065
|
+
const normalizedType = this.normalizeLessonFileType(params.type);
|
|
1066
|
+
|
|
312
1067
|
if (params.overwrite) {
|
|
313
1068
|
await (this.prisma as any).course_lesson_file.deleteMany({
|
|
314
1069
|
where: {
|
|
315
1070
|
course_lesson_id: params.lessonId,
|
|
316
|
-
|
|
1071
|
+
type: normalizedType,
|
|
317
1072
|
},
|
|
318
1073
|
});
|
|
319
1074
|
}
|
|
@@ -323,26 +1078,52 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
323
1078
|
course_lesson_id: params.lessonId,
|
|
324
1079
|
file_id: params.fileId,
|
|
325
1080
|
title: params.title,
|
|
326
|
-
|
|
327
|
-
|
|
1081
|
+
type: normalizedType,
|
|
1082
|
+
is_public: params.public,
|
|
328
1083
|
},
|
|
329
1084
|
});
|
|
330
1085
|
}
|
|
331
1086
|
|
|
1087
|
+
private normalizeLessonFileType(value?: string | null) {
|
|
1088
|
+
if (value === 'video_original') {
|
|
1089
|
+
return 'video_original';
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (value?.startsWith('video_profile:')) {
|
|
1093
|
+
return value;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (value === 'lesson_audio') {
|
|
1097
|
+
return 'lesson_audio';
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (value === 'student_download' || value === 'file' || value === 'download') {
|
|
1101
|
+
return 'student_download';
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return 'supplementary_material';
|
|
1105
|
+
}
|
|
1106
|
+
|
|
332
1107
|
private async convert(
|
|
333
1108
|
inputPath: string,
|
|
334
1109
|
outputPath: string,
|
|
335
1110
|
params: string,
|
|
336
1111
|
timeoutMs: number,
|
|
337
1112
|
) {
|
|
1113
|
+
const ffmpegCommand = this.getFfmpegCommand();
|
|
338
1114
|
const args = ['-y', '-i', inputPath, ...this.splitFfmpegParams(params), outputPath];
|
|
339
1115
|
try {
|
|
340
|
-
await execFileAsync(
|
|
1116
|
+
await execFileAsync(ffmpegCommand, args, {
|
|
341
1117
|
maxBuffer: 1024 * 1024 * 20,
|
|
342
1118
|
timeout: timeoutMs,
|
|
343
1119
|
windowsHide: true,
|
|
344
1120
|
});
|
|
345
1121
|
} catch (error: any) {
|
|
1122
|
+
if (error?.code === 'ENOENT') {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`FFmpeg binary not found. Configure FFMPEG_PATH or ensure "${ffmpegCommand}" is available in PATH`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
346
1127
|
if (error?.killed && error?.signal === 'SIGTERM') {
|
|
347
1128
|
throw new Error(`FFmpeg timed out after ${timeoutMs}ms`);
|
|
348
1129
|
}
|
|
@@ -350,6 +1131,176 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
350
1131
|
}
|
|
351
1132
|
}
|
|
352
1133
|
|
|
1134
|
+
private getFfmpegCommand(): string {
|
|
1135
|
+
const fromEnv = process.env.FFMPEG_PATH?.trim();
|
|
1136
|
+
if (fromEnv) {
|
|
1137
|
+
return fromEnv;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (process.platform === 'win32') {
|
|
1141
|
+
const fromWinget = this.findWindowsWingetFfmpeg();
|
|
1142
|
+
if (fromWinget) {
|
|
1143
|
+
return fromWinget;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return 'ffmpeg';
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private getFfprobeCommand(): string {
|
|
1151
|
+
const fromEnv = process.env.FFPROBE_PATH?.trim();
|
|
1152
|
+
if (fromEnv) {
|
|
1153
|
+
return fromEnv;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
|
|
1157
|
+
if (ffmpegEnv) {
|
|
1158
|
+
const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
|
|
1159
|
+
if (existsSync(candidate)) {
|
|
1160
|
+
return candidate;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (process.platform === 'win32') {
|
|
1165
|
+
const fromWinget = this.findWindowsWingetFfprobe();
|
|
1166
|
+
if (fromWinget) {
|
|
1167
|
+
return fromWinget;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return 'ffprobe';
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private findWindowsWingetFfprobe(): string | null {
|
|
1175
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
1176
|
+
if (!localAppData) {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const wingetPackagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
const packageDirs = readdirSync(wingetPackagesRoot, { withFileTypes: true })
|
|
1184
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
|
|
1185
|
+
.map((entry) => join(wingetPackagesRoot, entry.name))
|
|
1186
|
+
.sort((a, b) => b.localeCompare(a));
|
|
1187
|
+
|
|
1188
|
+
for (const packageDir of packageDirs) {
|
|
1189
|
+
const executable = this.findFfprobeExecutableInside(packageDir);
|
|
1190
|
+
if (executable) {
|
|
1191
|
+
return executable;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private findFfprobeExecutableInside(baseDir: string): string | null {
|
|
1202
|
+
const directCandidate = join(baseDir, 'bin', 'ffprobe.exe');
|
|
1203
|
+
if (existsSync(directCandidate)) {
|
|
1204
|
+
return directCandidate;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
try {
|
|
1208
|
+
const versionDirs = readdirSync(baseDir, { withFileTypes: true })
|
|
1209
|
+
.filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
|
|
1210
|
+
.map((entry) => join(baseDir, entry.name))
|
|
1211
|
+
.sort((a, b) => b.localeCompare(a));
|
|
1212
|
+
|
|
1213
|
+
for (const versionDir of versionDirs) {
|
|
1214
|
+
const candidate = join(versionDir, 'bin', 'ffprobe.exe');
|
|
1215
|
+
if (existsSync(candidate)) {
|
|
1216
|
+
return candidate;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} catch {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
private async probeDurationSeconds(inputPath: string, timeoutMs: number): Promise<number | null> {
|
|
1227
|
+
const ffprobeCommand = this.getFfprobeCommand();
|
|
1228
|
+
const args = [
|
|
1229
|
+
'-v', 'error',
|
|
1230
|
+
'-show_entries', 'format=duration',
|
|
1231
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
1232
|
+
inputPath,
|
|
1233
|
+
];
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
const { stdout } = await execFileAsync(ffprobeCommand, args, {
|
|
1237
|
+
maxBuffer: 1024 * 1024,
|
|
1238
|
+
timeout: Math.min(timeoutMs, 30_000),
|
|
1239
|
+
windowsHide: true,
|
|
1240
|
+
});
|
|
1241
|
+
const parsed = parseFloat(stdout.trim());
|
|
1242
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1243
|
+
return null;
|
|
1244
|
+
}
|
|
1245
|
+
return Math.round(parsed);
|
|
1246
|
+
} catch (error: any) {
|
|
1247
|
+
this.logger.warn(`FFprobe duration extraction failed: ${error?.message ?? error}`);
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private findWindowsWingetFfmpeg(): string | null {
|
|
1253
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
1254
|
+
if (!localAppData) {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const wingetPackagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
1259
|
+
|
|
1260
|
+
try {
|
|
1261
|
+
const packageDirs = readdirSync(wingetPackagesRoot, { withFileTypes: true })
|
|
1262
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('Gyan.FFmpeg'))
|
|
1263
|
+
.map((entry) => join(wingetPackagesRoot, entry.name))
|
|
1264
|
+
.sort((a, b) => b.localeCompare(a));
|
|
1265
|
+
|
|
1266
|
+
for (const packageDir of packageDirs) {
|
|
1267
|
+
const executable = this.findFfmpegExecutableInside(packageDir);
|
|
1268
|
+
if (executable) {
|
|
1269
|
+
return executable;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
} catch {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
private findFfmpegExecutableInside(baseDir: string): string | null {
|
|
1280
|
+
const directCandidate = join(baseDir, 'bin', 'ffmpeg.exe');
|
|
1281
|
+
if (existsSync(directCandidate)) {
|
|
1282
|
+
return directCandidate;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
try {
|
|
1286
|
+
const versionDirs = readdirSync(baseDir, { withFileTypes: true })
|
|
1287
|
+
.filter((entry) => entry.isDirectory() && entry.name.toLowerCase().startsWith('ffmpeg-'))
|
|
1288
|
+
.map((entry) => join(baseDir, entry.name))
|
|
1289
|
+
.sort((a, b) => b.localeCompare(a));
|
|
1290
|
+
|
|
1291
|
+
for (const versionDir of versionDirs) {
|
|
1292
|
+
const candidate = join(versionDir, 'bin', 'ffmpeg.exe');
|
|
1293
|
+
if (existsSync(candidate)) {
|
|
1294
|
+
return candidate;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
} catch {
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
353
1304
|
private getPositiveIntegerEnv(name: string): number | undefined {
|
|
354
1305
|
const raw = process.env[name];
|
|
355
1306
|
if (!raw) {
|
|
@@ -364,6 +1315,21 @@ export class CourseVideoConversionService implements OnModuleInit, IJobHandler {
|
|
|
364
1315
|
return Math.floor(value);
|
|
365
1316
|
}
|
|
366
1317
|
|
|
1318
|
+
private resolveFrameIntervalSeconds(value: unknown): number {
|
|
1319
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
1320
|
+
return Math.max(1, Math.floor(value));
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (typeof value === 'string') {
|
|
1324
|
+
const parsed = Number(value);
|
|
1325
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
1326
|
+
return Math.max(1, Math.floor(parsed));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return 10;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
367
1333
|
private calculateMBps(bytes: number, durationMs: number): number {
|
|
368
1334
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
369
1335
|
return 0;
|