@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.
- package/dist/certificate/certificate.controller.d.ts +1 -1
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +4 -2
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +50 -0
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +73 -0
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/class-group/class-group.controller.d.ts +1 -0
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.service.d.ts +1 -0
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/course/course-ai-usage.service.d.ts +58 -0
- package/dist/course/course-ai-usage.service.d.ts.map +1 -0
- package/dist/course/course-ai-usage.service.js +176 -0
- package/dist/course/course-ai-usage.service.js.map +1 -0
- package/dist/course/course-audio-transcription.service.d.ts +65 -1
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
- package/dist/course/course-audio-transcription.service.js +381 -29
- package/dist/course/course-audio-transcription.service.js.map +1 -1
- package/dist/course/course-export-scorm12.service.d.ts +3 -0
- package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
- package/dist/course/course-export-scorm12.service.js +141 -6
- package/dist/course/course-export-scorm12.service.js.map +1 -1
- package/dist/course/course-export.service.d.ts.map +1 -1
- package/dist/course/course-export.service.js +2 -1
- package/dist/course/course-export.service.js.map +1 -1
- package/dist/course/course-lesson.controller.d.ts +25 -3
- package/dist/course/course-lesson.controller.d.ts.map +1 -1
- package/dist/course/course-lesson.controller.js +71 -8
- package/dist/course/course-lesson.controller.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +30 -7
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +37 -4
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +37 -5
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +165 -20
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-transcription-translation.service.d.ts +31 -0
- package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
- package/dist/course/course-transcription-translation.service.js +227 -0
- package/dist/course/course-transcription-translation.service.js.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +398 -0
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
- package/dist/course/course-video-hls.service.d.ts +14 -0
- package/dist/course/course-video-hls.service.d.ts.map +1 -1
- package/dist/course/course-video-hls.service.js +25 -8
- package/dist/course/course-video-hls.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +2 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +9 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +36 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
- package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
- package/dist/course/dto/create-course-export.dto.d.ts +1 -0
- package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-export.dto.js +6 -0
- package/dist/course/dto/create-course-export.dto.js.map +1 -1
- package/dist/course/ffmpeg.util.d.ts +10 -0
- package/dist/course/ffmpeg.util.d.ts.map +1 -0
- package/dist/course/ffmpeg.util.js +79 -0
- package/dist/course/ffmpeg.util.js.map +1 -0
- package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +33 -16
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.d.ts +3 -0
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.js +48 -29
- package/dist/course/lms-bulk-upload.service.js.map +1 -1
- package/dist/course/subtitle.util.d.ts +46 -0
- package/dist/course/subtitle.util.d.ts.map +1 -0
- package/dist/course/subtitle.util.js +206 -0
- package/dist/course/subtitle.util.js.map +1 -0
- package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.d.ts +2 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.d.ts +27 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.js +197 -10
- package/dist/enterprise/training/training-student.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
- package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +14 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
- package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
- package/dist/platforma/dto/heartbeat.dto.js +50 -0
- package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
- package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.js +50 -0
- package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
- package/dist/platforma/platforma-performance.service.d.ts +121 -0
- package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
- package/dist/platforma/platforma-performance.service.js +500 -0
- package/dist/platforma/platforma-performance.service.js.map +1 -0
- package/dist/platforma/platforma-search.service.d.ts +21 -0
- package/dist/platforma/platforma-search.service.d.ts.map +1 -0
- package/dist/platforma/platforma-search.service.js +64 -0
- package/dist/platforma/platforma-search.service.js.map +1 -0
- package/dist/platforma/platforma-video.service.d.ts +8 -0
- package/dist/platforma/platforma-video.service.d.ts.map +1 -1
- package/dist/platforma/platforma-video.service.js +45 -2
- package/dist/platforma/platforma-video.service.js.map +1 -1
- package/dist/platforma/platforma.controller.d.ts +213 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +159 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.controller.d.ts +2 -0
- package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.controller.js +31 -0
- package/dist/realtime/lms-realtime.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.service.d.ts +1 -1
- package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.service.js.map +1 -1
- package/dist/training/dto/create-training.dto.d.ts +9 -0
- package/dist/training/dto/create-training.dto.d.ts.map +1 -1
- package/dist/training/dto/create-training.dto.js +45 -1
- package/dist/training/dto/create-training.dto.js.map +1 -1
- package/dist/training/training.controller.d.ts +144 -0
- package/dist/training/training.controller.d.ts.map +1 -1
- package/dist/training/training.service.d.ts +149 -0
- package/dist/training/training.service.d.ts.map +1 -1
- package/dist/training/training.service.js +332 -167
- package/dist/training/training.service.js.map +1 -1
- package/hedhog/data/image_type.yaml +10 -0
- package/hedhog/data/route.yaml +251 -0
- package/hedhog/data/setting_group.yaml +97 -0
- package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
- package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
- package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
- package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
- package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
- package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
- package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
- package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
- package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
- package/hedhog/frontend/messages/en.json +41 -12
- package/hedhog/frontend/messages/pt.json +44 -13
- package/hedhog/query/triggers.sql +33 -0
- package/hedhog/table/course_ai_usage.yaml +46 -0
- package/hedhog/table/course_enrollment.yaml +3 -0
- package/hedhog/table/course_lesson.yaml +3 -0
- package/hedhog/table/course_lesson_answer.yaml +37 -0
- package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
- package/hedhog/table/learning_path.yaml +6 -0
- package/hedhog/table/learning_path_module.yaml +22 -0
- package/hedhog/table/learning_path_step.yaml +9 -6
- package/hedhog/table/lesson_view_event.yaml +66 -0
- package/package.json +8 -7
- package/src/certificate/certificate.controller.ts +2 -0
- package/src/certificate/certificate.service.ts +99 -0
- package/src/course/course-ai-usage.service.ts +221 -0
- package/src/course/course-audio-transcription.service.ts +471 -43
- package/src/course/course-export-scorm12.service.ts +149 -5
- package/src/course/course-export.service.ts +1 -0
- package/src/course/course-lesson.controller.ts +59 -6
- package/src/course/course-structure.controller.ts +19 -1
- package/src/course/course-structure.service.ts +184 -10
- package/src/course/course-transcription-translation.service.ts +293 -0
- package/src/course/course-video-agent-pipeline.service.ts +471 -0
- package/src/course/course-video-hls.service.ts +30 -10
- package/src/course/course.module.ts +9 -0
- package/src/course/course.service.ts +46 -1
- package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
- package/src/course/dto/create-course-export.dto.ts +6 -0
- package/src/course/ffmpeg.util.ts +65 -0
- package/src/course/lms-bulk-upload-automation.service.ts +33 -8
- package/src/course/lms-bulk-upload.service.ts +20 -1
- package/src/course/subtitle.util.ts +220 -0
- package/src/enterprise/training/training-student.service.ts +224 -4
- package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
- package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
- package/src/lms.module.ts +14 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -0
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
- package/src/platforma/platforma-heartbeat.service.ts +33 -0
- package/src/platforma/platforma-performance.service.ts +606 -0
- package/src/platforma/platforma-search.service.ts +48 -0
- package/src/platforma/platforma-video.service.ts +59 -3
- package/src/platforma/platforma.controller.ts +130 -0
- package/src/realtime/lms-realtime.controller.ts +27 -1
- package/src/realtime/lms-realtime.service.ts +2 -1
- package/src/training/dto/create-training.dto.ts +36 -0
- 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
|
-
|
|
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.
|
|
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" = '
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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" = '
|
|
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
|
+
}
|