@hed-hog/lms 0.0.366 → 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 (169) 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/course/course-ai-usage.service.d.ts +58 -0
  10. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  11. package/dist/course/course-ai-usage.service.js +176 -0
  12. package/dist/course/course-ai-usage.service.js.map +1 -0
  13. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  14. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  15. package/dist/course/course-audio-transcription.service.js +381 -29
  16. package/dist/course/course-audio-transcription.service.js.map +1 -1
  17. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  18. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  19. package/dist/course/course-export-scorm12.service.js +141 -6
  20. package/dist/course/course-export-scorm12.service.js.map +1 -1
  21. package/dist/course/course-export.service.d.ts.map +1 -1
  22. package/dist/course/course-export.service.js +2 -1
  23. package/dist/course/course-export.service.js.map +1 -1
  24. package/dist/course/course-lesson.controller.d.ts +25 -3
  25. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  26. package/dist/course/course-lesson.controller.js +71 -8
  27. package/dist/course/course-lesson.controller.js.map +1 -1
  28. package/dist/course/course-structure.controller.d.ts +26 -5
  29. package/dist/course/course-structure.controller.d.ts.map +1 -1
  30. package/dist/course/course-structure.controller.js +31 -1
  31. package/dist/course/course-structure.controller.js.map +1 -1
  32. package/dist/course/course-structure.service.d.ts +37 -5
  33. package/dist/course/course-structure.service.d.ts.map +1 -1
  34. package/dist/course/course-structure.service.js +165 -20
  35. package/dist/course/course-structure.service.js.map +1 -1
  36. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  37. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  38. package/dist/course/course-transcription-translation.service.js +227 -0
  39. package/dist/course/course-transcription-translation.service.js.map +1 -0
  40. package/dist/course/course-video-agent-pipeline.service.js +7 -7
  41. package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
  42. package/dist/course/course.module.d.ts.map +1 -1
  43. package/dist/course/course.module.js +4 -0
  44. package/dist/course/course.module.js.map +1 -1
  45. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  46. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  47. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  48. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  49. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  50. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  51. package/dist/course/dto/create-course-export.dto.js +6 -0
  52. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  53. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  54. package/dist/course/lms-bulk-upload-automation.service.js +26 -13
  55. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  56. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  57. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  58. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  59. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  60. package/dist/course/lms-bulk-upload.service.js +48 -29
  61. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  62. package/dist/course/subtitle.util.d.ts +46 -0
  63. package/dist/course/subtitle.util.d.ts.map +1 -0
  64. package/dist/course/subtitle.util.js +206 -0
  65. package/dist/course/subtitle.util.js.map +1 -0
  66. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  67. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  68. package/dist/enterprise/training/training-student.service.js +197 -10
  69. package/dist/enterprise/training/training-student.service.js.map +1 -1
  70. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  71. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  72. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  73. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  74. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  75. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  76. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  77. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  78. package/dist/lms.module.d.ts.map +1 -1
  79. package/dist/lms.module.js +4 -0
  80. package/dist/lms.module.js.map +1 -1
  81. package/dist/platforma/platforma-performance.service.js +121 -121
  82. package/dist/platforma/platforma-video.service.d.ts +8 -0
  83. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  84. package/dist/platforma/platforma-video.service.js +45 -2
  85. package/dist/platforma/platforma-video.service.js.map +1 -1
  86. package/dist/platforma/platforma.controller.d.ts +99 -1
  87. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  88. package/dist/platforma/platforma.controller.js +111 -2
  89. package/dist/platforma/platforma.controller.js.map +1 -1
  90. package/dist/training/dto/create-training.dto.d.ts +9 -0
  91. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  92. package/dist/training/dto/create-training.dto.js +45 -1
  93. package/dist/training/dto/create-training.dto.js.map +1 -1
  94. package/dist/training/training.controller.d.ts +144 -0
  95. package/dist/training/training.controller.d.ts.map +1 -1
  96. package/dist/training/training.service.d.ts +149 -0
  97. package/dist/training/training.service.d.ts.map +1 -1
  98. package/dist/training/training.service.js +332 -167
  99. package/dist/training/training.service.js.map +1 -1
  100. package/hedhog/data/image_type.yaml +10 -0
  101. package/hedhog/data/route.yaml +251 -0
  102. package/hedhog/data/setting_group.yaml +97 -0
  103. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  105. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  106. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  112. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  113. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  114. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  116. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  117. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  118. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  119. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  120. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  121. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  122. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  123. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  124. package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
  125. package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
  126. package/hedhog/frontend/messages/en.json +23 -12
  127. package/hedhog/frontend/messages/pt.json +23 -12
  128. package/hedhog/query/triggers.sql +33 -0
  129. package/hedhog/table/course_ai_usage.yaml +46 -0
  130. package/hedhog/table/course_lesson.yaml +3 -0
  131. package/hedhog/table/course_lesson_answer.yaml +37 -0
  132. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  133. package/hedhog/table/learning_path.yaml +6 -0
  134. package/hedhog/table/learning_path_module.yaml +22 -0
  135. package/hedhog/table/learning_path_step.yaml +9 -6
  136. package/hedhog/table/lesson_view_event.yaml +66 -66
  137. package/package.json +9 -9
  138. package/src/certificate/certificate.controller.ts +2 -0
  139. package/src/certificate/certificate.service.ts +99 -0
  140. package/src/course/course-ai-usage.service.ts +221 -0
  141. package/src/course/course-audio-transcription.service.ts +471 -43
  142. package/src/course/course-export-scorm12.service.ts +149 -5
  143. package/src/course/course-export.service.ts +1 -0
  144. package/src/course/course-lesson.controller.ts +59 -6
  145. package/src/course/course-structure.controller.ts +16 -0
  146. package/src/course/course-structure.service.ts +184 -10
  147. package/src/course/course-transcription-translation.service.ts +293 -0
  148. package/src/course/course-video-agent-pipeline.service.ts +471 -471
  149. package/src/course/course.module.ts +4 -0
  150. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  151. package/src/course/dto/create-course-export.dto.ts +6 -0
  152. package/src/course/ffmpeg.util.ts +65 -65
  153. package/src/course/lms-bulk-upload-automation.service.ts +29 -7
  154. package/src/course/lms-bulk-upload.service.ts +20 -1
  155. package/src/course/subtitle.util.ts +220 -0
  156. package/src/enterprise/training/training-student.service.ts +224 -4
  157. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  158. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  159. package/src/lms.module.ts +4 -0
  160. package/src/platforma/dto/heartbeat.dto.ts +30 -30
  161. package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
  162. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
  163. package/src/platforma/platforma-heartbeat.service.ts +33 -33
  164. package/src/platforma/platforma-performance.service.ts +606 -606
  165. package/src/platforma/platforma-search.service.ts +48 -48
  166. package/src/platforma/platforma-video.service.ts +59 -3
  167. package/src/platforma/platforma.controller.ts +88 -0
  168. package/src/training/dto/create-training.dto.ts +36 -0
  169. package/src/training/training.service.ts +360 -163
@@ -7,6 +7,7 @@ import { promises as fs } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { finished } from 'stream/promises';
9
9
  import { CourseExportService, VideoLessonWithFile } from './course-export.service';
10
+ import { balanceSubtitleLines } from './subtitle.util';
10
11
  import {
11
12
  ADLCP_ROOTV1P2_XSD,
12
13
  IMSCP_ROOTV1P1P2_XSD,
@@ -25,7 +26,7 @@ export type ScormVisualOptions = {
25
26
 
26
27
  export type Scorm12BuildParams = {
27
28
  courseId: number;
28
- settings: { visual?: ScormVisualOptions };
29
+ settings: { visual?: ScormVisualOptions; subtitleLocaleIds?: number[] };
29
30
  workDir: string;
30
31
  onProgress: (pct: number, msg: string) => Promise<void>;
31
32
  };
@@ -35,6 +36,7 @@ type LessonData = {
35
36
  title: string;
36
37
  duration: number | null;
37
38
  src: string;
39
+ subtitles: Array<{ code: string; name: string; vttFile: string }>;
38
40
  };
39
41
 
40
42
  type ModuleData = {
@@ -128,7 +130,39 @@ export class CourseExportScorm12Service {
128
130
  }
129
131
  }
130
132
 
131
- const courseData = this.buildCourseData(courseTitle, lessons, videoSrcMap);
133
+ const subtitleLocaleIds = settings.subtitleLocaleIds ?? [];
134
+ const subtitleMap: Record<number, Array<{ code: string; name: string; vttFile: string }>> = {};
135
+ const vttFileList: string[] = [];
136
+
137
+ if (subtitleLocaleIds.length > 0) {
138
+ await onProgress(72, 'Gerando arquivos de legenda...');
139
+ const locales = await (this.prisma as any).locale.findMany({
140
+ where: { id: { in: subtitleLocaleIds } },
141
+ select: { id: true, code: true, name: true },
142
+ }) as Array<{ id: number; code: string; name: string }>;
143
+ const localeMap = new Map(locales.map((l: { id: number; code: string; name: string }) => [l.id, l]));
144
+
145
+ for (const lesson of lessons) {
146
+ for (const localeId of subtitleLocaleIds) {
147
+ const locale = localeMap.get(localeId);
148
+ if (!locale) continue;
149
+ const segments = await (this.prisma as any).course_lesson_transcription_segment.findMany({
150
+ where: { course_lesson_id: lesson.id, locale_id: localeId },
151
+ select: { start_seconds: true, end_seconds: true, text: true },
152
+ orderBy: { start_seconds: 'asc' },
153
+ }) as Array<{ start_seconds: number; end_seconds: number; text: string }>;
154
+ if (segments.length === 0) continue;
155
+ const vttContent = this.generateVttContent(segments);
156
+ const vttName = `assets/lesson_${lesson.id}_${locale.code}.vtt`;
157
+ archive.append(Buffer.from(vttContent, 'utf-8'), { name: vttName });
158
+ vttFileList.push(vttName);
159
+ if (!subtitleMap[lesson.id]) subtitleMap[lesson.id] = [];
160
+ subtitleMap[lesson.id].push({ code: locale.code, name: locale.name, vttFile: vttName });
161
+ }
162
+ }
163
+ }
164
+
165
+ const courseData = this.buildCourseData(courseTitle, lessons, videoSrcMap, subtitleMap);
132
166
 
133
167
  archive.append(Buffer.from(this.generateScormApiJs()), { name: 'SCORM12API.js' });
134
168
  archive.append(Buffer.from(IMS_XML_XSD), { name: 'ims_xml.xsd' });
@@ -140,7 +174,7 @@ export class CourseExportScorm12Service {
140
174
  );
141
175
  archive.append(Buffer.from(this.generateShellHtml(courseData, visual)), { name: 'index.html' });
142
176
 
143
- const manifestXml = this.generateManifest(courseTitle, embeddedLessonIds);
177
+ const manifestXml = this.generateManifest(courseTitle, embeddedLessonIds, vttFileList);
144
178
  archive.append(Buffer.from(manifestXml), { name: 'imsmanifest.xml' });
145
179
 
146
180
  await onProgress(80, 'Finalizando pacote ZIP...');
@@ -166,6 +200,7 @@ export class CourseExportScorm12Service {
166
200
  courseTitle: string,
167
201
  lessons: VideoLessonWithFile[],
168
202
  videoSrcMap: Record<number, string>,
203
+ subtitleMap: Record<number, Array<{ code: string; name: string; vttFile: string }>>,
169
204
  ): CourseData {
170
205
  const sessionMap = new Map<number, ModuleData>();
171
206
 
@@ -184,6 +219,7 @@ export class CourseExportScorm12Service {
184
219
  title: lesson.title,
185
220
  duration: lesson.duration_seconds,
186
221
  src: videoSrcMap[lesson.id] || '',
222
+ subtitles: subtitleMap[lesson.id] ?? [],
187
223
  });
188
224
  }
189
225
 
@@ -204,10 +240,13 @@ export class CourseExportScorm12Service {
204
240
  private generateManifest(
205
241
  courseTitle: string,
206
242
  embeddedLessonIds: number[],
243
+ vttFiles: string[],
207
244
  ): string {
208
245
  const manifestId = `manifest-${Date.now()}`;
209
- const assetFiles = embeddedLessonIds.length
210
- ? embeddedLessonIds.map((id) => ` <file href="assets/lesson_${id}.mp4"/>`).join('\n') + '\n'
246
+ const videoLines = embeddedLessonIds.map((id) => ` <file href="assets/lesson_${id}.mp4"/>`);
247
+ const vttLines = vttFiles.map((f) => ` <file href="${f}"/>`);
248
+ const assetFiles = [...videoLines, ...vttLines].length
249
+ ? [...videoLines, ...vttLines].join('\n') + '\n'
211
250
  : '';
212
251
 
213
252
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -347,6 +386,10 @@ ${this.generateCss(visual)}
347
386
  <div class="footer-meta" id="footerMeta"></div>
348
387
  </div>
349
388
  <div class="footer-nav">
389
+ <div class="sub-ctrl" id="subCtrl" style="display:none">
390
+ <button class="nav-btn" id="subtitleBtn" title="Legendas">CC</button>
391
+ <div id="subtitleMenu" class="subtitle-menu"></div>
392
+ </div>
350
393
  <button class="nav-btn" id="prevBtn" disabled>&#8592; Anterior</button>
351
394
  <button class="nav-btn primary" id="nextBtn" disabled>Pr&#243;xima &#8594;</button>
352
395
  </div>
@@ -436,6 +479,11 @@ ${this.generateJs(courseJson, visual.progressStyle)}
436
479
  .nav-btn.primary:hover:not(:disabled){filter:brightness(.85)}
437
480
  .menu-toggle{display:none;position:fixed;top:10px;z-index:30;width:34px;height:34px;border-radius:8px;background:rgba(0,0,0,.75);color:#fff;border:none;cursor:pointer;font-size:1rem;align-items:center;justify-content:center}
438
481
  .overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9}
482
+ .sub-ctrl{position:relative}
483
+ .subtitle-menu{display:none;position:absolute;bottom:calc(100% + 6px);right:0;background:#1f2937;border:1px solid #374151;border-radius:6px;min-width:140px;overflow:hidden;z-index:20}
484
+ .sub-item{padding:7px 12px;cursor:pointer;font-size:${fs.xs};color:#f3f4f6;white-space:nowrap}
485
+ .sub-item:hover{background:#374151}
486
+ .sub-item.active-sub{color:${pri};font-weight:600}
439
487
  @media(max-width:768px){
440
488
  .menu-toggle{display:flex}
441
489
  .sidebar{position:fixed;top:0;bottom:0;${isRtl ? 'right:0;left:auto' : 'left:0;right:auto'};transform:${isRtl ? 'translateX(100%)' : 'translateX(-100%)'}}
@@ -453,6 +501,7 @@ ${this.generateJs(courseJson, visual.progressStyle)}
453
501
  var currentIdx=-1;
454
502
  var allLessons=[];
455
503
  var _api=null;
504
+ var currentSubCode=null;
456
505
 
457
506
  COURSE.modules.forEach(function(m){
458
507
  m.lessons.forEach(function(l){l._mod=m.title;allLessons.push(l);});
@@ -554,6 +603,63 @@ ${this.generateJs(courseJson, visual.progressStyle)}
554
603
  });
555
604
  }
556
605
 
606
+ function applySubtitle(video,code){
607
+ var tracks=video.textTracks;
608
+ for(var i=0;i<tracks.length;i++){
609
+ tracks[i].mode=(code&&tracks[i].language===code)?'showing':'hidden';
610
+ }
611
+ var btn=document.getElementById('subtitleBtn');
612
+ if(btn)btn.style.opacity=code?'1':'0.5';
613
+ }
614
+
615
+ function highlightSubMenu(){
616
+ var menu=document.getElementById('subtitleMenu');
617
+ if(!menu)return;
618
+ menu.querySelectorAll('.sub-item').forEach(function(el){
619
+ el.classList.toggle('active-sub',(el.getAttribute('data-code')||null)===currentSubCode);
620
+ });
621
+ }
622
+
623
+ function buildSubMenu(subtitles,video){
624
+ var menu=document.getElementById('subtitleMenu');
625
+ if(!menu)return;
626
+ var html='<div class="sub-item" data-code="">Desligado</div>';
627
+ subtitles.forEach(function(sub){
628
+ html+='<div class="sub-item" data-code="'+sub.code+'">'+esc(sub.name)+'</div>';
629
+ });
630
+ menu.innerHTML=html;
631
+ menu.querySelectorAll('.sub-item').forEach(function(el){
632
+ el.addEventListener('click',function(e){
633
+ e.stopPropagation();
634
+ var code=el.getAttribute('data-code')||null;
635
+ currentSubCode=code;
636
+ applySubtitle(video,currentSubCode);
637
+ menu.style.display='none';
638
+ highlightSubMenu();
639
+ });
640
+ });
641
+ highlightSubMenu();
642
+ }
643
+
644
+ function updateSubtitleTracks(lesson,video){
645
+ var oldTracks=video.querySelectorAll('track');
646
+ oldTracks.forEach(function(t){video.removeChild(t);});
647
+ var subCtrl=document.getElementById('subCtrl');
648
+ if(!subCtrl)return;
649
+ if(!lesson.subtitles||!lesson.subtitles.length){subCtrl.style.display='none';return;}
650
+ subCtrl.style.display='';
651
+ lesson.subtitles.forEach(function(sub){
652
+ var t=document.createElement('track');
653
+ t.kind='subtitles';
654
+ t.src=sub.vttFile;
655
+ t.srclang=sub.code;
656
+ t.label=sub.name;
657
+ video.appendChild(t);
658
+ });
659
+ applySubtitle(video,currentSubCode);
660
+ buildSubMenu(lesson.subtitles,video);
661
+ }
662
+
557
663
  function navigateTo(idx){
558
664
  if(idx<0||idx>=allLessons.length)return;
559
665
  if(currentIdx>=0&&idx!==currentIdx)markCompleted(allLessons[currentIdx].id);
@@ -572,6 +678,7 @@ ${this.generateJs(courseJson, visual.progressStyle)}
572
678
  empty.textContent='Esta aula n\\u00e3o possui v\\u00eddeo dispon\\u00edvel';
573
679
  empty.style.display='block';
574
680
  }
681
+ updateSubtitleTracks(lesson,video);
575
682
  document.getElementById('footerTitle').textContent=lesson.title;
576
683
  var meta=[];
577
684
  if(lesson._mod)meta.push(lesson._mod);
@@ -610,6 +717,21 @@ ${this.generateJs(courseJson, visual.progressStyle)}
610
717
  document.getElementById('prevBtn').addEventListener('click',function(){navigateTo(currentIdx-1);});
611
718
  document.getElementById('nextBtn').addEventListener('click',function(){navigateTo(currentIdx+1);});
612
719
 
720
+ (function(){
721
+ var sbtn=document.getElementById('subtitleBtn');
722
+ var smenu=document.getElementById('subtitleMenu');
723
+ if(sbtn&&smenu){
724
+ sbtn.addEventListener('click',function(e){
725
+ e.stopPropagation();
726
+ smenu.style.display=smenu.style.display==='block'?'none':'block';
727
+ highlightSubMenu();
728
+ });
729
+ }
730
+ document.addEventListener('click',function(){
731
+ if(smenu)smenu.style.display='none';
732
+ });
733
+ })();
734
+
613
735
  (function(){
614
736
  var sidebar=document.getElementById('sidebar');
615
737
  var overlay=document.getElementById('overlay');
@@ -650,6 +772,28 @@ ${this.generateJs(courseJson, visual.progressStyle)}
650
772
  });`;
651
773
  }
652
774
 
775
+ private generateVttContent(
776
+ segments: Array<{ start_seconds: number; end_seconds: number; text: string }>,
777
+ ): string {
778
+ const lines = ['WEBVTT', ''];
779
+ for (const seg of segments) {
780
+ lines.push(
781
+ `${this.secondsToVttTime(seg.start_seconds)} --> ${this.secondsToVttTime(seg.end_seconds)}`,
782
+ balanceSubtitleLines(seg.text),
783
+ '',
784
+ );
785
+ }
786
+ return lines.join('\n');
787
+ }
788
+
789
+ private secondsToVttTime(s: number): string {
790
+ const total = Math.max(0, s);
791
+ const h = Math.floor(total / 3600);
792
+ const m = Math.floor((total % 3600) / 60);
793
+ const sec = total % 60;
794
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${sec.toFixed(3).padStart(6, '0')}`;
795
+ }
796
+
653
797
  private escapeXml(str: string): string {
654
798
  return str
655
799
  .replace(/&/g, '&amp;')
@@ -109,6 +109,7 @@ export class CourseExportService {
109
109
  ) {
110
110
  const settings = {
111
111
  visual: { ...(dto.visualSettings ?? {}) },
112
+ subtitleLocaleIds: dto.subtitleLocaleIds ?? [],
112
113
  };
113
114
 
114
115
  const exportRecord = await this.prisma.course_export.create({
@@ -1,24 +1,65 @@
1
1
  import { Role } from '@hed-hog/api';
2
- import { Body, Controller, Get, Param, ParseIntPipe, Post, Put } from '@nestjs/common';
2
+ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
3
+ import { PlatformaVideoService } from '../platforma/platforma-video.service';
3
4
  import { CourseStructureService } from './course-structure.service';
4
5
  import { UpdateTranscriptionSegmentsDTO } from './dto/update-transcription-segments.dto';
5
6
 
6
7
  @Role()
7
8
  @Controller('lms/lessons')
8
9
  export class CourseLessonController {
9
- constructor(private readonly courseStructureService: CourseStructureService) {}
10
+ constructor(
11
+ private readonly courseStructureService: CourseStructureService,
12
+ private readonly platformaVideoService: PlatformaVideoService,
13
+ ) {}
10
14
 
11
15
  @Get(':id/transcription-segments')
12
- getTranscriptionSegments(@Param('id', ParseIntPipe) id: number) {
13
- return this.courseStructureService.getTranscriptionSegments(id);
16
+ getTranscriptionSegments(
17
+ @Param('id', ParseIntPipe) id: number,
18
+ @Query('locale_id') localeId?: string,
19
+ ) {
20
+ const parsedLocaleId =
21
+ localeId === 'null' ? null : localeId !== undefined ? parseInt(localeId, 10) : undefined;
22
+ return this.courseStructureService.getTranscriptionSegments(id, parsedLocaleId);
23
+ }
24
+
25
+ @Get(':id/transcription-locales')
26
+ getTranscriptionLocales(@Param('id', ParseIntPipe) id: number) {
27
+ return this.courseStructureService.getAvailableTranscriptionLocales(id);
14
28
  }
15
29
 
16
30
  @Put(':id/transcription-segments')
17
31
  updateTranscriptionSegments(
18
32
  @Param('id', ParseIntPipe) id: number,
19
33
  @Body() dto: UpdateTranscriptionSegmentsDTO,
34
+ @Query('locale_id') localeId?: string,
35
+ ) {
36
+ const parsedLocaleId =
37
+ localeId === 'null' ? null : localeId !== undefined ? parseInt(localeId, 10) : undefined;
38
+ return this.courseStructureService.updateTranscriptionSegments(id, dto, parsedLocaleId);
39
+ }
40
+
41
+ @Delete(':id/transcription-segments')
42
+ deleteTranscriptionLocale(
43
+ @Param('id', ParseIntPipe) id: number,
44
+ @Query('locale_id') localeId?: string,
45
+ ) {
46
+ const parsedLocaleId =
47
+ localeId === 'null' ? null : localeId !== undefined ? parseInt(localeId, 10) : null;
48
+ return this.courseStructureService.deleteTranscriptionLocale(id, parsedLocaleId);
49
+ }
50
+
51
+ @Get(':id/preview/hls-token')
52
+ getAdminHlsToken(@Param('id', ParseIntPipe) id: number) {
53
+ return this.platformaVideoService.generateAdminHlsToken(id);
54
+ }
55
+
56
+ @Get(':id/preview/subtitles-token')
57
+ getAdminSubtitlesToken(
58
+ @Param('id', ParseIntPipe) id: number,
59
+ @Query('locale_id') localeId?: string,
20
60
  ) {
21
- return this.courseStructureService.updateTranscriptionSegments(id, dto);
61
+ const parsedLocaleId = localeId !== undefined ? parseInt(localeId, 10) : undefined;
62
+ return this.platformaVideoService.generateAdminSubtitlesToken(id, parsedLocaleId);
22
63
  }
23
64
 
24
65
  @Get(':id/audio-files')
@@ -30,4 +71,16 @@ export class CourseLessonController {
30
71
  startTranscription(@Param('id', ParseIntPipe) id: number) {
31
72
  return this.courseStructureService.startTranscription(id);
32
73
  }
33
- }
74
+
75
+ @Post(':id/transcription/translate')
76
+ translateTranscription(
77
+ @Param('id', ParseIntPipe) id: number,
78
+ @Body() body: { targetLocaleId: number; sourceLocaleId?: number | null },
79
+ ) {
80
+ return this.courseStructureService.translateTranscription(
81
+ id,
82
+ body.sourceLocaleId ?? null,
83
+ body.targetLocaleId,
84
+ );
85
+ }
86
+ }
@@ -238,6 +238,21 @@ export class CourseStructureController {
238
238
  return this.courseStructureService.getVideoProcessingStats(courseId);
239
239
  }
240
240
 
241
+ @Get('transcription-locales')
242
+ getCourseTranscriptionLocales(@Param('id', ParseIntPipe) courseId: number) {
243
+ return this.courseStructureService.getCourseTranscriptionLocales(courseId);
244
+ }
245
+
246
+ @Delete('transcription-segments')
247
+ deleteAllCourseTranscriptions(@Param('id', ParseIntPipe) courseId: number) {
248
+ return this.courseStructureService.deleteAllCourseTranscriptions(courseId);
249
+ }
250
+
251
+ @Get('ai-costs')
252
+ getCourseAiCosts(@Param('id', ParseIntPipe) courseId: number) {
253
+ return this.courseStructureService.getCourseAiCosts(courseId);
254
+ }
255
+
241
256
  @Post('bulk-jobs')
242
257
  createBulkJobs(
243
258
  @User() user: any,
@@ -249,6 +264,7 @@ export class CourseStructureController {
249
264
  dto.jobType,
250
265
  user?.id ?? 0,
251
266
  dto.reprocessAlreadyProcessed,
267
+ dto.targetLocaleId,
252
268
  );
253
269
  }
254
270
  }
@@ -3,6 +3,7 @@ import { FileService } from '@hed-hog/core';
3
3
  import { QueueJobService } from '@hed-hog/queue';
4
4
  import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
5
5
  import { InstructorService } from '../instructor/instructor.service';
6
+ import { CourseAiUsageService } from './course-ai-usage.service';
6
7
  import { CourseOperationsIntegrationService } from './course-operations-integration.service';
7
8
  import { CourseVideoHlsService } from './course-video-hls.service';
8
9
  import { CreateCourseLessonFrameDto } from './dto/create-course-lesson-frame.dto';
@@ -45,8 +46,13 @@ export class CourseStructureService {
45
46
  private readonly operationsIntegration: CourseOperationsIntegrationService,
46
47
  private readonly queueJob: QueueJobService,
47
48
  private readonly courseVideoHlsService: CourseVideoHlsService,
49
+ private readonly aiUsageService: CourseAiUsageService,
48
50
  ) {}
49
51
 
52
+ getCourseAiCosts(courseId: number) {
53
+ return this.aiUsageService.getCourseAiCosts(courseId);
54
+ }
55
+
50
56
  async getStructure(courseId: number) {
51
57
  await this.ensureCourseExists(courseId);
52
58
 
@@ -61,7 +67,9 @@ export class CourseStructureService {
61
67
  course_lesson: {
62
68
  orderBy: { order: 'asc' },
63
69
  include: {
64
- course_lesson_file: true,
70
+ course_lesson_file: {
71
+ include: { file: { select: { size: true } } },
72
+ },
65
73
  course_lesson_video_frame: {
66
74
  orderBy: { id: 'asc' },
67
75
  include: {
@@ -162,11 +170,12 @@ export class CourseStructureService {
162
170
  parsedContent?.statusProducao,
163
171
  ),
164
172
  published: lessonPublishedById.get(lesson.id) ?? false,
173
+ temTranscricao: Boolean((lesson as any).has_transcription),
165
174
  recursos: lesson.course_lesson_file.map((fileLink) => ({
166
175
  id: String(fileLink.id),
167
176
  nome: fileLink.title,
168
177
  fileId: fileLink.file_id,
169
- tamanho: '',
178
+ tamanho: fileLink.file?.size ?? 0,
170
179
  type: this.readLessonFileType(fileLink),
171
180
  is_public: this.readLessonFileVisibility(fileLink),
172
181
  })),
@@ -1114,12 +1123,17 @@ export class CourseStructureService {
1114
1123
  return { lessons: result };
1115
1124
  }
1116
1125
 
1117
- async getTranscriptionSegments(lessonId: number) {
1126
+ async getTranscriptionSegments(lessonId: number, localeId?: number | null) {
1127
+ const where: Record<string, any> = { course_lesson_id: lessonId };
1128
+ if (localeId !== undefined) {
1129
+ where.locale_id = localeId ?? null;
1130
+ }
1118
1131
  return (this.prisma as any).course_lesson_transcription_segment.findMany({
1119
- where: { course_lesson_id: lessonId },
1132
+ where,
1120
1133
  orderBy: { start_seconds: 'asc' },
1121
1134
  select: {
1122
1135
  id: true,
1136
+ locale_id: true,
1123
1137
  start_seconds: true,
1124
1138
  end_seconds: true,
1125
1139
  text: true,
@@ -1127,17 +1141,44 @@ export class CourseStructureService {
1127
1141
  });
1128
1142
  }
1129
1143
 
1144
+ async getAvailableTranscriptionLocales(lessonId: number) {
1145
+ const rows = await (this.prisma as any).course_lesson_transcription_segment.findMany({
1146
+ where: { course_lesson_id: lessonId },
1147
+ select: { locale_id: true },
1148
+ distinct: ['locale_id'],
1149
+ });
1150
+
1151
+ const localeIds: (number | null)[] = rows.map((r: any) => r.locale_id);
1152
+
1153
+ const locales = await Promise.all(
1154
+ localeIds.map(async (id) => {
1155
+ if (id === null) return { id: null, code: null, name: null, region: null };
1156
+ const locale = await (this.prisma as any).locale.findUnique({
1157
+ where: { id },
1158
+ select: { id: true, code: true, name: true, region: true },
1159
+ });
1160
+ return locale ?? { id, code: null, name: null, region: null };
1161
+ }),
1162
+ );
1163
+
1164
+ return locales;
1165
+ }
1166
+
1130
1167
  async updateTranscriptionSegments(
1131
1168
  lessonId: number,
1132
1169
  dto: UpdateTranscriptionSegmentsDTO,
1170
+ localeId?: number | null,
1133
1171
  ) {
1172
+ const where: Record<string, any> = { course_lesson_id: lessonId };
1173
+ if (localeId !== undefined) {
1174
+ where.locale_id = localeId ?? null;
1175
+ }
1134
1176
  return this.prisma.$transaction([
1135
- (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1136
- where: { course_lesson_id: lessonId },
1137
- }),
1177
+ (this.prisma as any).course_lesson_transcription_segment.deleteMany({ where }),
1138
1178
  (this.prisma as any).course_lesson_transcription_segment.createMany({
1139
1179
  data: (dto.segments ?? []).map((segment) => ({
1140
1180
  course_lesson_id: lessonId,
1181
+ locale_id: localeId ?? null,
1141
1182
  start_seconds: segment.startSeconds,
1142
1183
  end_seconds: segment.endSeconds,
1143
1184
  text: segment.text,
@@ -1146,6 +1187,69 @@ export class CourseStructureService {
1146
1187
  ]);
1147
1188
  }
1148
1189
 
1190
+ async deleteTranscriptionLocale(lessonId: number, localeId: number | null) {
1191
+ const result = await (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1192
+ where: { course_lesson_id: lessonId, locale_id: localeId },
1193
+ });
1194
+ return { success: true, deleted: result.count };
1195
+ }
1196
+
1197
+ async deleteAllCourseTranscriptions(courseId: number) {
1198
+ const lessons = await (this.prisma as any).course_lesson.findMany({
1199
+ where: { course_module: { course_id: courseId } },
1200
+ select: { id: true },
1201
+ });
1202
+ const lessonIds = lessons.map((l: any) => l.id);
1203
+ if (lessonIds.length === 0) {
1204
+ return { success: true, deleted: 0 };
1205
+ }
1206
+ const result = await (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1207
+ where: { course_lesson_id: { in: lessonIds } },
1208
+ });
1209
+ return { success: true, deleted: result.count };
1210
+ }
1211
+
1212
+ async translateTranscription(
1213
+ lessonId: number,
1214
+ sourceLocaleId: number | null,
1215
+ targetLocaleId: number,
1216
+ notificationId?: number,
1217
+ notificationUserId?: number,
1218
+ ) {
1219
+ const lesson = await (this.prisma as any).course_lesson.findUnique({
1220
+ where: { id: lessonId },
1221
+ select: { id: true },
1222
+ });
1223
+
1224
+ if (!lesson) {
1225
+ throw new NotFoundException(`Lesson ${lessonId} not found`);
1226
+ }
1227
+
1228
+ const job = await this.queueJob.enqueue({
1229
+ type: 'lms.transcription.translate',
1230
+ queueName: 'lms.transcription.translate',
1231
+ payload: {
1232
+ lessonId,
1233
+ sourceLocaleId,
1234
+ targetLocaleId,
1235
+ ...(notificationId != null ? { notificationId } : {}),
1236
+ ...(notificationUserId != null ? { notificationUserId } : {}),
1237
+ },
1238
+ sourceModule: 'lms',
1239
+ sourceEntity: 'course_lesson',
1240
+ sourceEntityId: `${lessonId}:${targetLocaleId}`,
1241
+ maxAttempts: 3,
1242
+ });
1243
+
1244
+ const startedOnDemand = await this.startTranscriptionOnDemandIfNeeded(job.id);
1245
+
1246
+ if (startedOnDemand) {
1247
+ return { queueJobId: job.id, status: 'processing' };
1248
+ }
1249
+
1250
+ return { queueJobId: job.id, status: job.status };
1251
+ }
1252
+
1149
1253
  async getAudioFiles(lessonId: number) {
1150
1254
  return (this.prisma as any).course_lesson_file.findMany({
1151
1255
  where: {
@@ -1270,9 +1374,10 @@ export class CourseStructureService {
1270
1374
 
1271
1375
  async bulkEnqueueJobs(
1272
1376
  courseId: number,
1273
- jobType: 'transcription' | 'xp_recalculation' | 'video_processing',
1377
+ jobType: 'transcription' | 'xp_recalculation' | 'video_processing' | 'translate_transcription',
1274
1378
  userId: number,
1275
1379
  reprocessAlreadyProcessed?: boolean,
1380
+ targetLocaleId?: number,
1276
1381
  ): Promise<{
1277
1382
  queued: number;
1278
1383
  skipped: number;
@@ -1363,7 +1468,7 @@ export class CourseStructureService {
1363
1468
  skipped++;
1364
1469
  }
1365
1470
  }
1366
- } else {
1471
+ } else if (jobType === 'xp_recalculation') {
1367
1472
  for (const lesson of lessons) {
1368
1473
  const segments = await (this.prisma as any).course_lesson_transcription_segment.findFirst({
1369
1474
  where: { course_lesson_id: lesson.id },
@@ -1409,11 +1514,78 @@ export class CourseStructureService {
1409
1514
  `${skippedWithoutTranscription} aula(s) sem transcrição foram ignoradas. Gere a transcrição antes de recalcular o XP.`,
1410
1515
  );
1411
1516
  }
1517
+ } else if (jobType === 'translate_transcription') {
1518
+ if (!targetLocaleId) {
1519
+ warnings.push('targetLocaleId é obrigatório para o job de tradução em massa.');
1520
+ return { queued, skipped, skippedWithoutTranscription, warnings };
1521
+ }
1522
+
1523
+ for (const lesson of lessons) {
1524
+ const sourceSegment = await (this.prisma as any).course_lesson_transcription_segment.findFirst({
1525
+ where: {
1526
+ course_lesson_id: lesson.id,
1527
+ NOT: { locale_id: targetLocaleId },
1528
+ },
1529
+ select: { id: true, locale_id: true },
1530
+ orderBy: { id: 'asc' },
1531
+ });
1532
+
1533
+ if (!sourceSegment) {
1534
+ skipped++;
1535
+ skippedWithoutTranscription++;
1536
+ continue;
1537
+ }
1538
+
1539
+ await this.queueJob.enqueue({
1540
+ type: 'lms.transcription.translate',
1541
+ queueName: 'lms.transcription.translate',
1542
+ payload: {
1543
+ lessonId: lesson.id,
1544
+ sourceLocaleId: sourceSegment.locale_id ?? null,
1545
+ targetLocaleId,
1546
+ },
1547
+ sourceModule: 'lms',
1548
+ sourceEntity: 'course_lesson',
1549
+ sourceEntityId: `${lesson.id}:${targetLocaleId}`,
1550
+ maxAttempts: 3,
1551
+ });
1552
+ queued++;
1553
+ }
1554
+
1555
+ if (skippedWithoutTranscription > 0) {
1556
+ warnings.push(
1557
+ `${skippedWithoutTranscription} aula(s) sem transcrição foram ignoradas.`,
1558
+ );
1559
+ }
1412
1560
  }
1413
1561
 
1414
1562
  return { queued, skipped, skippedWithoutTranscription, warnings };
1415
1563
  }
1416
1564
 
1565
+ async getCourseTranscriptionLocales(courseId: number) {
1566
+ const rows = await (this.prisma as any).course_lesson_transcription_segment.findMany({
1567
+ where: {
1568
+ course_lesson: {
1569
+ course_module: { course_id: courseId },
1570
+ },
1571
+ },
1572
+ select: { locale_id: true },
1573
+ distinct: ['locale_id'],
1574
+ });
1575
+
1576
+ const localeIds = (rows as Array<{ locale_id: number | null }>)
1577
+ .map((r) => r.locale_id)
1578
+ .filter((id): id is number => id !== null);
1579
+
1580
+ if (localeIds.length === 0) return [];
1581
+
1582
+ return (this.prisma as any).locale.findMany({
1583
+ where: { id: { in: localeIds } },
1584
+ select: { id: true, code: true, name: true, region: true },
1585
+ orderBy: { name: 'asc' },
1586
+ });
1587
+ }
1588
+
1417
1589
  private async syncLessonRelations(
1418
1590
  lessonId: number,
1419
1591
  dto: Partial<
@@ -1510,7 +1682,9 @@ export class CourseStructureService {
1510
1682
  },
1511
1683
  },
1512
1684
  },
1513
- course_lesson_file: true,
1685
+ course_lesson_file: {
1686
+ include: { file: { select: { size: true } } },
1687
+ },
1514
1688
  course_lesson_question: {
1515
1689
  orderBy: { order: 'asc' },
1516
1690
  },