@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.
Files changed (135) hide show
  1. package/dist/course/course-audio-transcription.service.d.ts +29 -0
  2. package/dist/course/course-audio-transcription.service.d.ts.map +1 -0
  3. package/dist/course/course-audio-transcription.service.js +291 -0
  4. package/dist/course/course-audio-transcription.service.js.map +1 -0
  5. package/dist/course/course-lesson.controller.d.ts +10 -0
  6. package/dist/course/course-lesson.controller.d.ts.map +1 -0
  7. package/dist/course/course-lesson.controller.js +62 -0
  8. package/dist/course/course-lesson.controller.js.map +1 -0
  9. package/dist/course/course-structure.controller.d.ts +41 -15
  10. package/dist/course/course-structure.controller.d.ts.map +1 -1
  11. package/dist/course/course-structure.controller.js +50 -6
  12. package/dist/course/course-structure.controller.js.map +1 -1
  13. package/dist/course/course-structure.service.d.ts +50 -15
  14. package/dist/course/course-structure.service.d.ts.map +1 -1
  15. package/dist/course/course-structure.service.js +238 -73
  16. package/dist/course/course-structure.service.js.map +1 -1
  17. package/dist/course/course-video-conversion.service.d.ts +20 -2
  18. package/dist/course/course-video-conversion.service.d.ts.map +1 -1
  19. package/dist/course/course-video-conversion.service.js +730 -10
  20. package/dist/course/course-video-conversion.service.js.map +1 -1
  21. package/dist/course/course.controller.d.ts +24 -8
  22. package/dist/course/course.controller.d.ts.map +1 -1
  23. package/dist/course/course.module.d.ts.map +1 -1
  24. package/dist/course/course.module.js +5 -3
  25. package/dist/course/course.module.js.map +1 -1
  26. package/dist/course/course.service.d.ts +24 -8
  27. package/dist/course/course.service.d.ts.map +1 -1
  28. package/dist/course/course.service.js +112 -176
  29. package/dist/course/course.service.js.map +1 -1
  30. package/dist/course/dto/create-course-lesson-frame.dto.d.ts +5 -0
  31. package/dist/course/dto/create-course-lesson-frame.dto.d.ts.map +1 -0
  32. package/dist/course/dto/create-course-lesson-frame.dto.js +30 -0
  33. package/dist/course/dto/create-course-lesson-frame.dto.js.map +1 -0
  34. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +4 -2
  35. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  36. package/dist/course/dto/create-course-structure-lesson.dto.js +10 -3
  37. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  38. package/dist/course/dto/create-course.dto.d.ts +1 -1
  39. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  40. package/dist/course/dto/create-course.dto.js +6 -6
  41. package/dist/course/dto/create-course.dto.js.map +1 -1
  42. package/dist/course/dto/update-course-lesson-frame.dto.d.ts +5 -0
  43. package/dist/course/dto/update-course-lesson-frame.dto.d.ts.map +1 -0
  44. package/dist/course/dto/update-course-lesson-frame.dto.js +32 -0
  45. package/dist/course/dto/update-course-lesson-frame.dto.js.map +1 -0
  46. package/dist/course/dto/update-course-resources.dto.d.ts +4 -2
  47. package/dist/course/dto/update-course-resources.dto.d.ts.map +1 -1
  48. package/dist/course/dto/update-course-resources.dto.js +10 -3
  49. package/dist/course/dto/update-course-resources.dto.js.map +1 -1
  50. package/dist/course/dto/update-transcription-segments.dto.d.ts +10 -0
  51. package/dist/course/dto/update-transcription-segments.dto.d.ts.map +1 -0
  52. package/dist/course/dto/update-transcription-segments.dto.js +38 -0
  53. package/dist/course/dto/update-transcription-segments.dto.js.map +1 -0
  54. package/dist/course/lms-setting.controller.d.ts +13 -0
  55. package/dist/course/lms-setting.controller.d.ts.map +1 -0
  56. package/dist/course/lms-setting.controller.js +53 -0
  57. package/dist/course/lms-setting.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  59. package/dist/enterprise/training/training-admin.service.js +74 -33
  60. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/lms.module.d.ts.map +1 -1
  66. package/dist/lms.module.js +6 -0
  67. package/dist/lms.module.js.map +1 -1
  68. package/hedhog/data/route.yaml +63 -0
  69. package/hedhog/data/setting_group.yaml +76 -0
  70. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +3 -0
  71. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +10 -1
  72. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +7 -2
  73. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +5 -2
  74. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +7 -1
  75. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +3 -0
  76. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +95 -50
  77. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +11 -36
  78. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +19 -5
  79. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  80. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +30 -10
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/CourseInstructorsSummaryCard.tsx.ejs +95 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +29 -11
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +42 -31
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +10 -1
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -9
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +25 -22
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +12 -9
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +315 -229
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +3220 -534
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +20 -15
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/icon-action-tooltip.tsx.ejs +35 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +76 -67
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +13 -10
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +18 -16
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +6 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +1 -1
  97. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +23 -11
  98. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +48 -9
  99. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +11 -2
  100. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +121 -15
  101. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +69 -14
  102. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +11 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +31 -0
  104. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +32 -6
  105. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +57 -3
  106. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +46 -6
  107. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +14 -0
  108. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-audio-files.ts.ejs +23 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +34 -0
  110. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +76 -0
  111. package/hedhog/frontend/messages/en.json +39 -3
  112. package/hedhog/frontend/messages/pt.json +39 -3
  113. package/hedhog/table/course.yaml +8 -0
  114. package/hedhog/table/course_lesson_file.yaml +12 -4
  115. package/hedhog/table/course_lesson_transcription_segment.yaml +22 -0
  116. package/hedhog/table/course_lesson_video_frame.yaml +25 -0
  117. package/package.json +8 -8
  118. package/src/course/course-audio-transcription.service.ts +393 -0
  119. package/src/course/course-lesson.controller.ts +28 -0
  120. package/src/course/course-structure.controller.ts +49 -3
  121. package/src/course/course-structure.service.ts +294 -32
  122. package/src/course/course-video-conversion.service.ts +972 -6
  123. package/src/course/course.module.ts +5 -3
  124. package/src/course/course.service.ts +87 -139
  125. package/src/course/dto/create-course-lesson-frame.dto.ts +14 -0
  126. package/src/course/dto/create-course-structure-lesson.dto.ts +19 -3
  127. package/src/course/dto/create-course.dto.ts +5 -5
  128. package/src/course/dto/update-course-lesson-frame.dto.ts +16 -0
  129. package/src/course/dto/update-course-resources.dto.ts +18 -3
  130. package/src/course/dto/update-transcription-segments.dto.ts +20 -0
  131. package/src/course/lms-setting.controller.ts +30 -0
  132. package/src/enterprise/training/training-admin.service.ts +77 -24
  133. package/src/index.ts +2 -0
  134. package/src/lms.module.ts +6 -0
  135. 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
- tipo: params.type,
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
- tipo: params.type,
327
- publico: params.public,
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('ffmpeg', args, {
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;