@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.
- 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/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 +26 -5
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +31 -1
- 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.js +7 -7
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +4 -0
- package/dist/course/course.module.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/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +26 -13
- 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-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 +4 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/platforma-performance.service.js +121 -121
- 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 +99 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +111 -2
- package/dist/platforma/platforma.controller.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/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/detail-course.tsx.ejs +69 -1
- 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/_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-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 +26 -4
- package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
- package/hedhog/frontend/messages/en.json +23 -12
- package/hedhog/frontend/messages/pt.json +23 -12
- package/hedhog/query/triggers.sql +33 -0
- package/hedhog/table/course_ai_usage.yaml +46 -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 -66
- package/package.json +9 -9
- 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 +16 -0
- 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 -471
- package/src/course/course.module.ts +4 -0
- 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 -65
- package/src/course/lms-bulk-upload-automation.service.ts +29 -7
- 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 +4 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -30
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
- package/src/platforma/platforma-heartbeat.service.ts +33 -33
- package/src/platforma/platforma-performance.service.ts +606 -606
- package/src/platforma/platforma-search.service.ts +48 -48
- package/src/platforma/platforma-video.service.ts +59 -3
- package/src/platforma/platforma.controller.ts +88 -0
- package/src/training/dto/create-training.dto.ts +36 -0
- package/src/training/training.service.ts +360 -163
|
@@ -4,6 +4,7 @@ import { CoreModule } from '@hed-hog/core';
|
|
|
4
4
|
import { QueueModule } from '@hed-hog/queue';
|
|
5
5
|
import { forwardRef, Module } from '@nestjs/common';
|
|
6
6
|
import { InstructorModule } from '../instructor/instructor.module';
|
|
7
|
+
import { CourseAiUsageService } from './course-ai-usage.service';
|
|
7
8
|
import { CourseLessonController } from './course-lesson.controller';
|
|
8
9
|
import { CourseOperationsIntegrationService } from './course-operations-integration.service';
|
|
9
10
|
import { CourseOperationsController } from './course-operations.controller';
|
|
@@ -24,6 +25,7 @@ import { LmsBulkUploadController } from './lms-bulk-upload.controller';
|
|
|
24
25
|
import { LmsBulkUploadService } from './lms-bulk-upload.service';
|
|
25
26
|
import { LmsOperationsTaskSubscriber } from './lms-operations-task.subscriber';
|
|
26
27
|
import { LmsSettingController } from './lms-setting.controller';
|
|
28
|
+
import { PlatformaVideoService } from '../platforma/platforma-video.service';
|
|
27
29
|
|
|
28
30
|
@Module({
|
|
29
31
|
imports: [
|
|
@@ -42,6 +44,7 @@ import { LmsSettingController } from './lms-setting.controller';
|
|
|
42
44
|
LmsBulkUploadController,
|
|
43
45
|
],
|
|
44
46
|
providers: [
|
|
47
|
+
CourseAiUsageService,
|
|
45
48
|
CourseExportService,
|
|
46
49
|
CourseExportScorm12Service,
|
|
47
50
|
CourseExportScorm12WorkerService,
|
|
@@ -56,6 +59,7 @@ import { LmsSettingController } from './lms-setting.controller';
|
|
|
56
59
|
LmsBulkUploadService,
|
|
57
60
|
LmsCoursesMcpTools,
|
|
58
61
|
LmsOperationsTaskSubscriber,
|
|
62
|
+
PlatformaVideoService,
|
|
59
63
|
],
|
|
60
64
|
exports: [
|
|
61
65
|
forwardRef(() => CourseService),
|
|
@@ -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
|
}
|
|
@@ -1,65 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -271,7 +271,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
|
|
|
271
271
|
if (Number.isInteger(Number(payload.uploadItemId)) && Number(payload.uploadItemId) > 0) {
|
|
272
272
|
await this.prisma.$executeRawUnsafe(
|
|
273
273
|
`UPDATE "lms_bulk_upload_item"
|
|
274
|
-
SET "status" = '
|
|
274
|
+
SET "status" = 'received', "completed_at" = NOW(), "updated_at" = NOW()
|
|
275
275
|
WHERE "id" = $1`,
|
|
276
276
|
Number(payload.uploadItemId),
|
|
277
277
|
).catch(() => undefined);
|
|
@@ -377,7 +377,18 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
for (const lesson of session.course_lesson ?? []) {
|
|
380
|
-
|
|
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) {
|
|
381
392
|
continue;
|
|
382
393
|
}
|
|
383
394
|
|
|
@@ -411,15 +422,26 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
|
|
|
411
422
|
}
|
|
412
423
|
|
|
413
424
|
const RESOLUTION_SUFFIXES = new Set(['1080', '720', '480', '360', '240', '4k', '2k', 'uhd', 'fhd', 'hd', 'sd']);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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);
|
|
417
438
|
}
|
|
418
439
|
|
|
419
440
|
return {
|
|
420
441
|
sessionCode: this.normalizeComparableText(parts[0]),
|
|
421
442
|
courseCode: this.normalizeComparableText(parts[1]),
|
|
422
|
-
|
|
443
|
+
lessonCode,
|
|
444
|
+
lessonTitle: this.normalizeComparableText(rest.join('_')),
|
|
423
445
|
};
|
|
424
446
|
}
|
|
425
447
|
|
|
@@ -624,7 +646,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
|
|
|
624
646
|
SET "matched_course_id" = $2,
|
|
625
647
|
"matched_session_id" = $3,
|
|
626
648
|
"matched_lesson_id" = $4,
|
|
627
|
-
"status" = '
|
|
649
|
+
"status" = 'processing',
|
|
628
650
|
"updated_at" = $5
|
|
629
651
|
WHERE "id" = $1`,
|
|
630
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
|
+
}
|