@hed-hog/lms 0.0.365 → 0.0.370

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 (243) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/class-group/class-group.controller.d.ts +1 -0
  10. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  11. package/dist/class-group/class-group.service.d.ts +1 -0
  12. package/dist/class-group/class-group.service.d.ts.map +1 -1
  13. package/dist/course/course-ai-usage.service.d.ts +58 -0
  14. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  15. package/dist/course/course-ai-usage.service.js +176 -0
  16. package/dist/course/course-ai-usage.service.js.map +1 -0
  17. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  18. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  19. package/dist/course/course-audio-transcription.service.js +381 -29
  20. package/dist/course/course-audio-transcription.service.js.map +1 -1
  21. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  22. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  23. package/dist/course/course-export-scorm12.service.js +141 -6
  24. package/dist/course/course-export-scorm12.service.js.map +1 -1
  25. package/dist/course/course-export.service.d.ts.map +1 -1
  26. package/dist/course/course-export.service.js +2 -1
  27. package/dist/course/course-export.service.js.map +1 -1
  28. package/dist/course/course-lesson.controller.d.ts +25 -3
  29. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  30. package/dist/course/course-lesson.controller.js +71 -8
  31. package/dist/course/course-lesson.controller.js.map +1 -1
  32. package/dist/course/course-structure.controller.d.ts +30 -7
  33. package/dist/course/course-structure.controller.d.ts.map +1 -1
  34. package/dist/course/course-structure.controller.js +37 -4
  35. package/dist/course/course-structure.controller.js.map +1 -1
  36. package/dist/course/course-structure.service.d.ts +37 -5
  37. package/dist/course/course-structure.service.d.ts.map +1 -1
  38. package/dist/course/course-structure.service.js +165 -20
  39. package/dist/course/course-structure.service.js.map +1 -1
  40. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  41. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  42. package/dist/course/course-transcription-translation.service.js +227 -0
  43. package/dist/course/course-transcription-translation.service.js.map +1 -0
  44. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  45. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  46. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  47. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  48. package/dist/course/course-video-hls.service.d.ts +14 -0
  49. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  50. package/dist/course/course-video-hls.service.js +25 -8
  51. package/dist/course/course-video-hls.service.js.map +1 -1
  52. package/dist/course/course.controller.d.ts +2 -0
  53. package/dist/course/course.controller.d.ts.map +1 -1
  54. package/dist/course/course.module.d.ts.map +1 -1
  55. package/dist/course/course.module.js +9 -0
  56. package/dist/course/course.module.js.map +1 -1
  57. package/dist/course/course.service.d.ts +2 -0
  58. package/dist/course/course.service.d.ts.map +1 -1
  59. package/dist/course/course.service.js +36 -2
  60. package/dist/course/course.service.js.map +1 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  62. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  63. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  64. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  65. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  66. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  67. package/dist/course/dto/create-course-export.dto.js +6 -0
  68. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  69. package/dist/course/ffmpeg.util.d.ts +10 -0
  70. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  71. package/dist/course/ffmpeg.util.js +79 -0
  72. package/dist/course/ffmpeg.util.js.map +1 -0
  73. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  75. package/dist/course/lms-bulk-upload-automation.service.js +33 -16
  76. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  77. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  78. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  79. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  80. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  81. package/dist/course/lms-bulk-upload.service.js +48 -29
  82. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  83. package/dist/course/subtitle.util.d.ts +46 -0
  84. package/dist/course/subtitle.util.d.ts.map +1 -0
  85. package/dist/course/subtitle.util.js +206 -0
  86. package/dist/course/subtitle.util.js.map +1 -0
  87. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  88. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  89. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  90. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  91. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  92. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  93. package/dist/enterprise/training/training-student.service.js +197 -10
  94. package/dist/enterprise/training/training-student.service.js.map +1 -1
  95. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  96. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  97. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  98. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  99. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  100. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  101. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  102. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  103. package/dist/lms.module.d.ts.map +1 -1
  104. package/dist/lms.module.js +14 -0
  105. package/dist/lms.module.js.map +1 -1
  106. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  107. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  108. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  109. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  110. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  111. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  112. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  113. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  114. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  115. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  116. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  117. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  118. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  119. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  120. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  121. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  122. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  123. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  124. package/dist/platforma/platforma-performance.service.js +500 -0
  125. package/dist/platforma/platforma-performance.service.js.map +1 -0
  126. package/dist/platforma/platforma-search.service.d.ts +21 -0
  127. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  128. package/dist/platforma/platforma-search.service.js +64 -0
  129. package/dist/platforma/platforma-search.service.js.map +1 -0
  130. package/dist/platforma/platforma-video.service.d.ts +8 -0
  131. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  132. package/dist/platforma/platforma-video.service.js +45 -2
  133. package/dist/platforma/platforma-video.service.js.map +1 -1
  134. package/dist/platforma/platforma.controller.d.ts +213 -1
  135. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  136. package/dist/platforma/platforma.controller.js +159 -2
  137. package/dist/platforma/platforma.controller.js.map +1 -1
  138. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  139. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  140. package/dist/realtime/lms-realtime.controller.js +31 -0
  141. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  142. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  143. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  144. package/dist/realtime/lms-realtime.service.js.map +1 -1
  145. package/dist/training/dto/create-training.dto.d.ts +9 -0
  146. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  147. package/dist/training/dto/create-training.dto.js +45 -1
  148. package/dist/training/dto/create-training.dto.js.map +1 -1
  149. package/dist/training/training.controller.d.ts +144 -0
  150. package/dist/training/training.controller.d.ts.map +1 -1
  151. package/dist/training/training.service.d.ts +149 -0
  152. package/dist/training/training.service.d.ts.map +1 -1
  153. package/dist/training/training.service.js +332 -167
  154. package/dist/training/training.service.js.map +1 -1
  155. package/hedhog/data/image_type.yaml +10 -0
  156. package/hedhog/data/route.yaml +251 -0
  157. package/hedhog/data/setting_group.yaml +97 -0
  158. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  159. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  160. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  161. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  162. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  163. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  164. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  165. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  166. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  167. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  168. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  169. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  170. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  171. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  172. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  173. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  174. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  175. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  176. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  177. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  178. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  179. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  180. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  181. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  182. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  183. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  184. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  185. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  186. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  187. package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
  188. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  189. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  190. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  191. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  192. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  193. package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
  194. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  195. package/hedhog/frontend/messages/en.json +41 -12
  196. package/hedhog/frontend/messages/pt.json +44 -13
  197. package/hedhog/query/triggers.sql +33 -0
  198. package/hedhog/table/course_ai_usage.yaml +46 -0
  199. package/hedhog/table/course_enrollment.yaml +3 -0
  200. package/hedhog/table/course_lesson.yaml +3 -0
  201. package/hedhog/table/course_lesson_answer.yaml +37 -0
  202. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  203. package/hedhog/table/learning_path.yaml +6 -0
  204. package/hedhog/table/learning_path_module.yaml +22 -0
  205. package/hedhog/table/learning_path_step.yaml +9 -6
  206. package/hedhog/table/lesson_view_event.yaml +66 -0
  207. package/package.json +8 -7
  208. package/src/certificate/certificate.controller.ts +2 -0
  209. package/src/certificate/certificate.service.ts +99 -0
  210. package/src/course/course-ai-usage.service.ts +221 -0
  211. package/src/course/course-audio-transcription.service.ts +471 -43
  212. package/src/course/course-export-scorm12.service.ts +149 -5
  213. package/src/course/course-export.service.ts +1 -0
  214. package/src/course/course-lesson.controller.ts +59 -6
  215. package/src/course/course-structure.controller.ts +19 -1
  216. package/src/course/course-structure.service.ts +184 -10
  217. package/src/course/course-transcription-translation.service.ts +293 -0
  218. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  219. package/src/course/course-video-hls.service.ts +30 -10
  220. package/src/course/course.module.ts +9 -0
  221. package/src/course/course.service.ts +46 -1
  222. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  223. package/src/course/dto/create-course-export.dto.ts +6 -0
  224. package/src/course/ffmpeg.util.ts +65 -0
  225. package/src/course/lms-bulk-upload-automation.service.ts +33 -8
  226. package/src/course/lms-bulk-upload.service.ts +20 -1
  227. package/src/course/subtitle.util.ts +220 -0
  228. package/src/enterprise/training/training-student.service.ts +224 -4
  229. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  230. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  231. package/src/lms.module.ts +14 -0
  232. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  233. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  234. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  235. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  236. package/src/platforma/platforma-performance.service.ts +606 -0
  237. package/src/platforma/platforma-search.service.ts +48 -0
  238. package/src/platforma/platforma-video.service.ts +59 -3
  239. package/src/platforma/platforma.controller.ts +130 -0
  240. package/src/realtime/lms-realtime.controller.ts +27 -1
  241. package/src/realtime/lms-realtime.service.ts +2 -1
  242. package/src/training/dto/create-training.dto.ts +36 -0
  243. package/src/training/training.service.ts +360 -163
@@ -1905,6 +1905,7 @@ export class CourseService implements OnModuleInit, IJobHandler {
1905
1905
  extractedImageCount,
1906
1906
  resourceFileCount,
1907
1907
  storageRows,
1908
+ videoFileRows,
1908
1909
  ] = await Promise.all([
1909
1910
  this.prisma.course_module.count({ where: { course_id: courseId } }),
1910
1911
  this.prisma.course_lesson.findMany({
@@ -1930,6 +1931,17 @@ export class CourseService implements OnModuleInit, IJobHandler {
1930
1931
  },
1931
1932
  }),
1932
1933
  this.getCourseStorageRows(courseId),
1934
+ this.prisma.course_lesson_file.findMany({
1935
+ where: {
1936
+ course_lesson: { course_module: { course_id: courseId } },
1937
+ OR: [
1938
+ { type: 'video_original' },
1939
+ { type: { startsWith: 'video_profile:' } },
1940
+ ],
1941
+ file_id: { not: null },
1942
+ },
1943
+ select: { course_lesson_id: true, type: true },
1944
+ }),
1933
1945
  ]);
1934
1946
 
1935
1947
  const transcriptionLessonIds = new Set(
@@ -1937,10 +1949,23 @@ export class CourseService implements OnModuleInit, IJobHandler {
1937
1949
  );
1938
1950
  const xpLessonIds = new Set(xpMapRows.map((r) => r.course_lesson_id));
1939
1951
 
1952
+ const videoOriginalLessonIds = new Set(
1953
+ videoFileRows
1954
+ .filter((r) => r.type === 'video_original')
1955
+ .map((r) => r.course_lesson_id),
1956
+ );
1957
+ const videoProfileLessonIds = new Set(
1958
+ videoFileRows
1959
+ .filter((r) => r.type?.startsWith('video_profile:'))
1960
+ .map((r) => r.course_lesson_id),
1961
+ );
1962
+
1940
1963
  const lessonsByType = { video: 0, questao: 0, post: 0 };
1941
1964
  let publishedLessonCount = 0;
1942
1965
  let videoWithTranscription = 0;
1943
1966
  let videoWithXp = 0;
1967
+ let videoWithVideo = 0;
1968
+ let videoWithProcessedVideo = 0;
1944
1969
 
1945
1970
  const categoryOrder = [
1946
1971
  'video_original',
@@ -2009,8 +2034,9 @@ export class CourseService implements OnModuleInit, IJobHandler {
2009
2034
  if (lesson.published) publishedLessonCount++;
2010
2035
 
2011
2036
  let sourceType: string | undefined;
2037
+ let parsed: Record<string, unknown> | null = null;
2012
2038
  try {
2013
- const parsed = lesson.content
2039
+ parsed = lesson.content
2014
2040
  ? (JSON.parse(lesson.content as string) as Record<string, unknown>)
2015
2041
  : null;
2016
2042
  sourceType =
@@ -2041,6 +2067,23 @@ export class CourseService implements OnModuleInit, IJobHandler {
2041
2067
  if (uiType === 'video') {
2042
2068
  if (transcriptionLessonIds.has(lesson.id)) videoWithTranscription++;
2043
2069
  if (xpLessonIds.has(lesson.id)) videoWithXp++;
2070
+
2071
+ const videoUrl =
2072
+ typeof parsed?.videoUrl === 'string' ? parsed.videoUrl : '';
2073
+ const videoProvedor =
2074
+ typeof parsed?.videoProvedor === 'string'
2075
+ ? parsed.videoProvedor
2076
+ : 'file_storage';
2077
+
2078
+ const hasVideo =
2079
+ videoUrl.length > 0 || videoOriginalLessonIds.has(lesson.id);
2080
+ const isProcessed =
2081
+ videoProvedor !== 'file_storage'
2082
+ ? videoUrl.length > 0
2083
+ : videoProfileLessonIds.has(lesson.id);
2084
+
2085
+ if (hasVideo) videoWithVideo++;
2086
+ if (isProcessed) videoWithProcessedVideo++;
2044
2087
  }
2045
2088
  }
2046
2089
 
@@ -2055,6 +2098,8 @@ export class CourseService implements OnModuleInit, IJobHandler {
2055
2098
  lessonCount: lessonsByType.video,
2056
2099
  withTranscription: videoWithTranscription,
2057
2100
  withXp: videoWithXp,
2101
+ withVideo: videoWithVideo,
2102
+ withProcessedVideo: videoWithProcessedVideo,
2058
2103
  },
2059
2104
  media: { extractedImageCount },
2060
2105
  resources: { fileCount: resourceFileCount },
@@ -1,10 +1,14 @@
1
- import { IsBoolean, IsIn, IsOptional } from 'class-validator';
1
+ import { IsBoolean, IsIn, IsInt, IsOptional } from 'class-validator';
2
2
 
3
3
  export class CreateCourseBulkJobDto {
4
- @IsIn(['transcription', 'xp_recalculation', 'video_processing'])
5
- jobType: 'transcription' | 'xp_recalculation' | 'video_processing';
4
+ @IsIn(['transcription', 'xp_recalculation', 'video_processing', 'translate_transcription'])
5
+ jobType: 'transcription' | 'xp_recalculation' | 'video_processing' | 'translate_transcription';
6
6
 
7
7
  @IsBoolean()
8
8
  @IsOptional()
9
9
  reprocessAlreadyProcessed?: boolean;
10
+
11
+ @IsInt()
12
+ @IsOptional()
13
+ targetLocaleId?: number;
10
14
  }
@@ -1,5 +1,6 @@
1
1
  import { Type } from 'class-transformer';
2
2
  import {
3
+ IsArray,
3
4
  IsIn,
4
5
  IsInt,
5
6
  IsOptional,
@@ -53,4 +54,9 @@ export class CreateCourseExportDto {
53
54
  @ValidateNested()
54
55
  @Type(() => ScormVisualSettingsDto)
55
56
  visualSettings?: ScormVisualSettingsDto;
57
+
58
+ @IsOptional()
59
+ @IsArray()
60
+ @IsInt({ each: true })
61
+ subtitleLocaleIds?: number[];
56
62
  }
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Resolves the ffmpeg/ffprobe binaries across environments: explicit env override first,
6
+ * then a WinGet (Gyan.FFmpeg) lookup on Windows, finally the bare command on PATH.
7
+ * Mirrors the resolution baked into CourseVideoHlsService so the decomposed pipeline jobs
8
+ * (extract / split) behave identically.
9
+ */
10
+ export function getFfmpegCommand(): string {
11
+ const fromEnv = process.env.FFMPEG_PATH?.trim();
12
+ if (fromEnv) return fromEnv;
13
+ if (process.platform === 'win32') {
14
+ const found = findWindowsBinary('ffmpeg');
15
+ if (found) return found;
16
+ }
17
+ return 'ffmpeg';
18
+ }
19
+
20
+ export function getFfprobeCommand(): string {
21
+ const fromEnv = process.env.FFPROBE_PATH?.trim();
22
+ if (fromEnv) return fromEnv;
23
+ const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
24
+ if (ffmpegEnv) {
25
+ const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
26
+ if (existsSync(candidate)) return candidate;
27
+ }
28
+ if (process.platform === 'win32') {
29
+ const found = findWindowsBinary('ffprobe');
30
+ if (found) return found;
31
+ }
32
+ return 'ffprobe';
33
+ }
34
+
35
+ export function findWindowsBinary(name: 'ffmpeg' | 'ffprobe'): string | null {
36
+ const localAppData = process.env.LOCALAPPDATA;
37
+ if (!localAppData) return null;
38
+ const packagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
39
+ try {
40
+ const packageDirs = readdirSync(packagesRoot, { withFileTypes: true })
41
+ .filter((e) => e.isDirectory() && e.name.startsWith('Gyan.FFmpeg'))
42
+ .map((e) => join(packagesRoot, e.name))
43
+ .sort((a, b) => b.localeCompare(a));
44
+
45
+ for (const dir of packageDirs) {
46
+ const direct = join(dir, 'bin', `${name}.exe`);
47
+ if (existsSync(direct)) return direct;
48
+ try {
49
+ const versionDirs = readdirSync(dir, { withFileTypes: true })
50
+ .filter((e) => e.isDirectory() && e.name.toLowerCase().startsWith('ffmpeg-'))
51
+ .map((e) => join(dir, e.name))
52
+ .sort((a, b) => b.localeCompare(a));
53
+ for (const vd of versionDirs) {
54
+ const candidate = join(vd, 'bin', `${name}.exe`);
55
+ if (existsSync(candidate)) return candidate;
56
+ }
57
+ } catch {
58
+ /* skip */
59
+ }
60
+ }
61
+ } catch {
62
+ /* skip */
63
+ }
64
+ return null;
65
+ }
@@ -21,6 +21,7 @@ import { tmpdir } from 'os';
21
21
  import { basename, extname, join } from 'path';
22
22
  import { Readable } from 'stream';
23
23
  import { pipeline } from 'stream/promises';
24
+ import { CourseVideoAgentPipelineService } from './course-video-agent-pipeline.service';
24
25
  import { CourseVideoConversionService } from './course-video-conversion.service';
25
26
  import { CourseVideoHlsService } from './course-video-hls.service';
26
27
  import {
@@ -62,6 +63,8 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
62
63
  private readonly courseVideoConversionService: CourseVideoConversionService,
63
64
  @Inject(forwardRef(() => CourseVideoHlsService))
64
65
  private readonly courseVideoHlsService: CourseVideoHlsService,
66
+ @Inject(forwardRef(() => CourseVideoAgentPipelineService))
67
+ private readonly courseVideoAgentPipelineService: CourseVideoAgentPipelineService,
65
68
  ) {}
66
69
 
67
70
  onModuleInit(): void {
@@ -257,7 +260,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
257
260
  },
258
261
  );
259
262
 
260
- await this.courseVideoHlsService.enqueueHls({
263
+ await this.courseVideoAgentPipelineService.startProcessing({
261
264
  userId,
262
265
  courseId,
263
266
  sessionId,
@@ -268,7 +271,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
268
271
  if (Number.isInteger(Number(payload.uploadItemId)) && Number(payload.uploadItemId) > 0) {
269
272
  await this.prisma.$executeRawUnsafe(
270
273
  `UPDATE "lms_bulk_upload_item"
271
- SET "status" = 'done', "completed_at" = NOW(), "updated_at" = NOW()
274
+ SET "status" = 'received', "completed_at" = NOW(), "updated_at" = NOW()
272
275
  WHERE "id" = $1`,
273
276
  Number(payload.uploadItemId),
274
277
  ).catch(() => undefined);
@@ -374,7 +377,18 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
374
377
  }
375
378
 
376
379
  for (const lesson of session.course_lesson ?? []) {
377
- if (this.normalizeComparableText(lesson.title) !== parsed.lessonTitle) {
380
+ const lessonNumeric = parsed.lessonCode?.replace(/[^0-9]/g, '') ?? '';
381
+ const lessonOrderCode = String(
382
+ Math.max(Number(lesson.order ?? 0), 0),
383
+ ).padStart(2, '0');
384
+
385
+ const matchesByCode =
386
+ Boolean(lessonNumeric) && lessonNumeric === lessonOrderCode;
387
+ const matchesByTitle =
388
+ !parsed.lessonCode &&
389
+ this.normalizeComparableText(lesson.title) === parsed.lessonTitle;
390
+
391
+ if (!matchesByCode && !matchesByTitle) {
378
392
  continue;
379
393
  }
380
394
 
@@ -408,15 +422,26 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
408
422
  }
409
423
 
410
424
  const RESOLUTION_SUFFIXES = new Set(['1080', '720', '480', '360', '240', '4k', '2k', 'uhd', 'fhd', 'hd', 'sd']);
411
- let titleParts = parts.slice(2);
412
- if (titleParts.length > 1 && RESOLUTION_SUFFIXES.has(titleParts[titleParts.length - 1])) {
413
- titleParts = titleParts.slice(0, -1);
425
+
426
+ let rest = parts.slice(2);
427
+
428
+ // 4 segmentos: detecta o código da aula (ex.: "a01") como primeiro item de `rest`.
429
+ // Sem código da aula (formato antigo de 3 segmentos) -> lessonCode = null.
430
+ let lessonCode: string | null = null;
431
+ if (rest.length > 1 && /^a\d{2,}$/.test(rest[0])) {
432
+ lessonCode = rest[0];
433
+ rest = rest.slice(1);
434
+ }
435
+
436
+ if (rest.length > 1 && RESOLUTION_SUFFIXES.has(rest[rest.length - 1])) {
437
+ rest = rest.slice(0, -1);
414
438
  }
415
439
 
416
440
  return {
417
441
  sessionCode: this.normalizeComparableText(parts[0]),
418
442
  courseCode: this.normalizeComparableText(parts[1]),
419
- lessonTitle: this.normalizeComparableText(titleParts.join('_')),
443
+ lessonCode,
444
+ lessonTitle: this.normalizeComparableText(rest.join('_')),
420
445
  };
421
446
  }
422
447
 
@@ -621,7 +646,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
621
646
  SET "matched_course_id" = $2,
622
647
  "matched_session_id" = $3,
623
648
  "matched_lesson_id" = $4,
624
- "status" = 'received',
649
+ "status" = 'processing',
625
650
  "updated_at" = $5
626
651
  WHERE "id" = $1`,
627
652
  itemId,
@@ -726,6 +726,9 @@ export class LmsBulkUploadService {
726
726
  matched_session_title: string | null;
727
727
  matched_lesson_id: number | null;
728
728
  matched_lesson_title: string | null;
729
+ job_status: string | null;
730
+ job_attempts: number | null;
731
+ job_max_attempts: number | null;
729
732
  }>>(
730
733
  `SELECT
731
734
  i."id",
@@ -753,7 +756,10 @@ export class LmsBulkUploadService {
753
756
  ms."id" AS matched_session_id,
754
757
  ms."title" AS matched_session_title,
755
758
  ml."id" AS matched_lesson_id,
756
- ml."title" AS matched_lesson_title
759
+ ml."title" AS matched_lesson_title,
760
+ job."job_status",
761
+ job."job_attempts",
762
+ job."job_max_attempts"
757
763
  FROM "lms_bulk_upload_item" i
758
764
  JOIN "lms_bulk_upload_session" s ON s."id" = i."session_id"
759
765
  LEFT JOIN "user" u ON u."id" = s."user_id"
@@ -777,6 +783,16 @@ export class LmsBulkUploadService {
777
783
  ORDER BY ci."id" DESC
778
784
  LIMIT 1
779
785
  ) logo ON TRUE
786
+ LEFT JOIN LATERAL (
787
+ SELECT qj."status" AS job_status,
788
+ qj."attempts" AS job_attempts,
789
+ qj."max_attempts" AS job_max_attempts
790
+ FROM "queue_job" qj
791
+ WHERE qj."source_entity" = 'lms_bulk_upload_item'
792
+ AND qj."source_entity_id" = CAST(i."id" AS TEXT)
793
+ ORDER BY qj."id" DESC
794
+ LIMIT 1
795
+ ) job ON TRUE
780
796
  ${whereSql}
781
797
  ORDER BY i."updated_at" DESC
782
798
  LIMIT $${args.length + 1}
@@ -814,6 +830,9 @@ export class LmsBulkUploadService {
814
830
  matchedSessionTitle: row.matched_session_title,
815
831
  matchedLessonId: row.matched_lesson_id ? Number(row.matched_lesson_id) : null,
816
832
  matchedLessonTitle: row.matched_lesson_title,
833
+ jobStatus: row.job_status ?? null,
834
+ jobAttempts: row.job_attempts != null ? Number(row.job_attempts) : null,
835
+ jobMaxAttempts: row.job_max_attempts != null ? Number(row.job_max_attempts) : null,
817
836
  })),
818
837
  total,
819
838
  page,
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Subtitle helpers shared between the student player VTT endpoint and the
3
+ * SCORM export. Line breaking is a *presentation* concern: it is applied when
4
+ * the VTT is generated, never stored in the database, so that translations stay
5
+ * clean and the balancing adapts to each language's text length.
6
+ *
7
+ * Defaults follow the Netflix Timed Text Style Guide (pt-BR): max 42 chars per
8
+ * line, max 2 lines, balanced split that avoids ending a line on a short
9
+ * function word.
10
+ */
11
+
12
+ const FUNCTION_WORDS = new Set([
13
+ 'a', 'à', 'ao', 'aos', 'as', 'às', 'com', 'da', 'das', 'de', 'do', 'dos',
14
+ 'e', 'em', 'na', 'nas', 'no', 'nos', 'o', 'os', 'ou', 'para', 'pela',
15
+ 'pelas', 'pelo', 'pelos', 'por', 'que', 'se', 'um', 'uma', 'umas', 'uns',
16
+ ]);
17
+
18
+ /**
19
+ * Returns true when the text fits in at most two lines that each respect the
20
+ * per-line character limit, breaking only between words. Used while grouping
21
+ * subtitle cues so a cue never grows past what can be displayed in two lines
22
+ * (measuring only the total length is not enough — a long word near the middle
23
+ * can force the second line over the limit).
24
+ */
25
+ export function fitsInTwoLines(text: string, maxCharsPerLine = 42): boolean {
26
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
27
+ if (clean.length <= maxCharsPerLine) return true;
28
+
29
+ const words = clean.split(' ');
30
+ let line1 = '';
31
+ for (let i = 0; i < words.length - 1; i += 1) {
32
+ line1 = line1 ? `${line1} ${words[i]}` : words[i];
33
+ // Once line 1 overflows, later splits only make it longer — give up.
34
+ if (line1.length > maxCharsPerLine) break;
35
+ const line2 = words.slice(i + 1).join(' ');
36
+ if (line2.length <= maxCharsPerLine) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Splits a single-line subtitle text into at most two balanced lines using a
43
+ * `\n`, respecting the per-line character limit. Returns the text unchanged
44
+ * when it already fits in a single line. Any existing whitespace (including
45
+ * stray newlines) is normalized before balancing, so it is safe to call on
46
+ * legacy segments too.
47
+ */
48
+ export function balanceSubtitleLines(
49
+ text: string,
50
+ maxCharsPerLine = 42,
51
+ ): string {
52
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
53
+ if (clean.length <= maxCharsPerLine) return clean;
54
+
55
+ const words = clean.split(' ');
56
+ let line1 = '';
57
+ let best: { idx: number; cost: number } | null = null;
58
+
59
+ for (let i = 0; i < words.length - 1; i += 1) {
60
+ line1 = line1 ? `${line1} ${words[i]}` : words[i];
61
+ // Once line 1 overflows it can only get longer — stop searching.
62
+ if (line1.length > maxCharsPerLine) break;
63
+
64
+ const line2 = words.slice(i + 1).join(' ');
65
+ let cost = Math.abs(line1.length - line2.length);
66
+ // Strongly penalize a second line that does not fit.
67
+ if (line2.length > maxCharsPerLine) cost += 1000;
68
+ // Avoid ending the first line on a short function word.
69
+ const tail = (words[i] ?? '').toLowerCase().replace(/[^\p{L}]/gu, '');
70
+ if (FUNCTION_WORDS.has(tail)) cost += 6;
71
+
72
+ if (best === null || cost < best.cost) {
73
+ best = { idx: i, cost };
74
+ }
75
+ }
76
+
77
+ if (!best) return clean;
78
+
79
+ const first = words.slice(0, best.idx + 1).join(' ');
80
+ const second = words.slice(best.idx + 1).join(' ');
81
+ return `${first}\n${second}`;
82
+ }
83
+
84
+ export type TimedWord = { word: string; start: number; end: number };
85
+
86
+ /**
87
+ * Normalizes a word for cross-transcript comparison: lowercase, strip accents
88
+ * and any non-letter/digit characters. Used only to decide whether two tokens
89
+ * are "the same" during alignment — the original (clean) text is preserved.
90
+ */
91
+ function normalizeToken(word: string): string {
92
+ return String(word ?? '')
93
+ .toLowerCase()
94
+ .normalize('NFD')
95
+ .replace(/[̀-ͯ]/g, '')
96
+ .replace(/[^a-z0-9]/g, '');
97
+ }
98
+
99
+ /**
100
+ * Merges the *text* of a high-quality transcript (e.g. gpt-4o-transcribe, which
101
+ * returns no timestamps) with the *timing* of a Whisper transcript (real word
102
+ * timestamps). The two word sequences are aligned via LCS over normalized
103
+ * tokens; each clean word inherits a timestamp:
104
+ * - matched → the timed word's start/end
105
+ * - inserted → interpolated across the gap between surrounding anchors
106
+ * - deleted → the timed word is dropped (its time is absorbed by neighbours)
107
+ *
108
+ * Falls back to the raw timed words when the texts diverge too much (match ratio
109
+ * below `minMatchRatio`) — a slightly worse text with correct timing beats a
110
+ * confident-but-wrong alignment. Pure function (no I/O) for easy unit testing.
111
+ */
112
+ export function alignTextToTimings(
113
+ timedWords: TimedWord[],
114
+ cleanText: string,
115
+ minMatchRatio = 0.5,
116
+ ): TimedWord[] {
117
+ const cleanWords = String(cleanText ?? '')
118
+ .split(/\s+/)
119
+ .map((w) => w.trim())
120
+ .filter(Boolean);
121
+
122
+ if (cleanWords.length === 0) return timedWords;
123
+ if (timedWords.length === 0) {
124
+ // No timing available — caller decides; return clean words with zero timing
125
+ // so they are not silently lost (buildSubtitleSegments tolerates it).
126
+ return cleanWords.map((word) => ({ word, start: 0, end: 0 }));
127
+ }
128
+
129
+ const a = timedWords.map((w) => normalizeToken(w.word));
130
+ const b = cleanWords.map((w) => normalizeToken(w));
131
+
132
+ // LCS table over normalized tokens.
133
+ const n = a.length;
134
+ const m = b.length;
135
+ const lcs: number[][] = Array.from({ length: n + 1 }, () =>
136
+ new Array<number>(m + 1).fill(0),
137
+ );
138
+ for (let i = n - 1; i >= 0; i--) {
139
+ for (let j = m - 1; j >= 0; j--) {
140
+ lcs[i][j] =
141
+ a[i] && a[i] === b[j]
142
+ ? lcs[i + 1][j + 1] + 1
143
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
144
+ }
145
+ }
146
+
147
+ // Walk the table to record, for each clean word index, the matched timed word
148
+ // index (or -1 when unmatched).
149
+ const matchOf = new Array<number>(m).fill(-1);
150
+ let i = 0;
151
+ let j = 0;
152
+ let matches = 0;
153
+ while (i < n && j < m) {
154
+ if (a[i] && a[i] === b[j]) {
155
+ matchOf[j] = i;
156
+ matches++;
157
+ i++;
158
+ j++;
159
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
160
+ i++;
161
+ } else {
162
+ j++;
163
+ }
164
+ }
165
+
166
+ if (matches / m < minMatchRatio) {
167
+ return timedWords;
168
+ }
169
+
170
+ // Assign timestamps to every clean word.
171
+ const result: TimedWord[] = new Array(m);
172
+ for (let k = 0; k < m; k++) {
173
+ const ti = matchOf[k];
174
+ if (ti >= 0) {
175
+ result[k] = {
176
+ word: cleanWords[k],
177
+ start: timedWords[ti].start,
178
+ end: timedWords[ti].end,
179
+ };
180
+ } else {
181
+ result[k] = { word: cleanWords[k], start: NaN, end: NaN };
182
+ }
183
+ }
184
+
185
+ // Fill unmatched runs by interpolating between the surrounding anchors.
186
+ // Outer boundaries: leading words spread from the very first timed start,
187
+ // trailing words spread up to the very last timed end (so a final correction
188
+ // keeps the leftover audio time instead of collapsing onto the last anchor).
189
+ const audioStart = timedWords[0].start;
190
+ const audioEnd = timedWords[timedWords.length - 1].end;
191
+
192
+ let k = 0;
193
+ while (k < m) {
194
+ if (!Number.isNaN(result[k].start)) {
195
+ k++;
196
+ continue;
197
+ }
198
+ // Find the run [k, end) of unmatched words.
199
+ let end = k;
200
+ while (end < m && Number.isNaN(result[end].start)) end++;
201
+
202
+ const prevEnd = k > 0 ? result[k - 1].end : audioStart;
203
+ const nextStart = end < m ? result[end].start : audioEnd;
204
+ const span = Math.max(0, nextStart - prevEnd);
205
+ const count = end - k;
206
+ const step = count > 0 ? span / count : 0;
207
+
208
+ for (let r = 0; r < count; r++) {
209
+ const s = prevEnd + step * r;
210
+ result[k + r] = {
211
+ word: result[k + r].word,
212
+ start: s,
213
+ end: prevEnd + step * (r + 1),
214
+ };
215
+ }
216
+ k = end;
217
+ }
218
+
219
+ return result;
220
+ }