feat: add configurable VDO.Ninja overview and floating window focus
This commit is contained in:
@@ -7,6 +7,7 @@ import { PlatformControlService } from '../platform-control/platform-control.ser
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||
import { serializeCourseMaps } from './course-maps';
|
||||
import { readPlatformCamera } from '../instructor/platform-camera';
|
||||
|
||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||
@@ -1099,6 +1100,7 @@ export class CurriculumService {
|
||||
organizationName: platform.organizationName,
|
||||
iconKey: platform.platform.iconKey,
|
||||
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
|
||||
camera: readPlatformCamera(platform.studioSettingsOverride),
|
||||
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
|
||||
platformKey: platform.platform.platformKey,
|
||||
vehicleType: platform.vehicleType,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { InstructorService } from './instructor.service';
|
||||
|
||||
describe('base course learning content copy', () => {
|
||||
it.each([true, false])('preserves learning concepts when present: %s', async (hasOverride) => {
|
||||
const steps = [{ title: 'SLAM', description: 'Build and save a map.', concepts: ['TF', 'Mapping', 'Odometry'], videoEnabled: false, videoUrl: '' }];
|
||||
const template = {
|
||||
organizationName: 'Source', weeks: [{ week: 1, topics: [{ title: 'SLAM', detail: 'Map', sortOrder: 1 }], practices: [], references: [], modules: [] }],
|
||||
environmentProfiles: [], maps: [], weekMaps: [],
|
||||
instructorCurriculumWeekOverrides: hasOverride ? [{ week: 1, organizationName: 'Source', learningSteps: steps }] : [],
|
||||
};
|
||||
const tx = {
|
||||
course: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'new-course' }) },
|
||||
courseWeek: { create: jest.fn().mockResolvedValue({ id: 'new-week', week: 1 }) },
|
||||
courseWeekTopic: { createMany: jest.fn() }, courseWeekPractice: { createMany: jest.fn() },
|
||||
courseWeekReference: { createMany: jest.fn() }, instructorCurriculumWeekOverride: { create: jest.fn() },
|
||||
};
|
||||
const service = Object.assign(Object.create(InstructorService.prototype), {
|
||||
prisma: {
|
||||
course: { findFirst: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(template) },
|
||||
$transaction: (callback: (client: typeof tx) => Promise<unknown>) => callback(tx),
|
||||
},
|
||||
});
|
||||
await service.createBaseCourseForInstructor('teacher', 'Destination', 'New course');
|
||||
if (hasOverride) {
|
||||
expect(tx.instructorCurriculumWeekOverride.create).toHaveBeenCalledWith({ data: {
|
||||
courseId: 'new-course', organizationName: 'Destination', week: 1, learningSteps: steps,
|
||||
topics: ['SLAM'], topicDetails: ['Build and save a map.'], topicVideoUrls: [''],
|
||||
} });
|
||||
} else {
|
||||
expect(tx.instructorCurriculumWeekOverride.create).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdateInstructorPlatformDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cameraViewUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cameraPassword?: string;
|
||||
|
||||
@IsString()
|
||||
displayName: string;
|
||||
|
||||
|
||||
@@ -122,10 +122,12 @@ export class InstructorController {
|
||||
deleteCurriculumWeek(
|
||||
@Req() request: Request,
|
||||
@Param('week', ParseIntPipe) week: number,
|
||||
@Query('courseId') courseId?: string,
|
||||
) {
|
||||
return this.instructorService.deleteCurriculumWeek(
|
||||
this.getBearerToken(request),
|
||||
week,
|
||||
courseId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dt
|
||||
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||
import { groupCourseScope } from './group-course-scope';
|
||||
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
|
||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||
@@ -1337,6 +1338,7 @@ export class InstructorService {
|
||||
type: this.toPlatformType(platform.platform.displayName),
|
||||
status: activeControl ? '활성' : '대기',
|
||||
battery: platform.battery,
|
||||
camera: readPlatformCamera(platform.studioSettingsOverride),
|
||||
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
||||
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
||||
: this.formatUptime(platform.uptimeMinutes),
|
||||
@@ -1462,6 +1464,7 @@ export class InstructorService {
|
||||
return {
|
||||
week: week.week,
|
||||
title: override?.title ?? week.title,
|
||||
canDelete: false,
|
||||
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
|
||||
progress: progressState.progress,
|
||||
state: progressState.state,
|
||||
@@ -1516,6 +1519,7 @@ export class InstructorService {
|
||||
return {
|
||||
week: override.week,
|
||||
title: override.title ?? `${override.week}주차 커리큘럼`,
|
||||
canDelete: true,
|
||||
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
|
||||
progress: progressState.progress,
|
||||
state: progressState.state,
|
||||
@@ -1907,13 +1911,13 @@ export class InstructorService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCurriculumWeek(accessToken: string | undefined, week: number) {
|
||||
async deleteCurriculumWeek(accessToken: string | undefined, week: number, courseId?: string | null) {
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationId = authUser.primaryAffiliation?.organizationId;
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id);
|
||||
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
|
||||
if (!course) throw new NotFoundException('Course not found');
|
||||
|
||||
const baseWeek = course.weeks.find((courseWeek) => courseWeek.week === week);
|
||||
@@ -2223,6 +2227,12 @@ export class InstructorService {
|
||||
const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90);
|
||||
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
|
||||
const algorithms = this.normalizeAlgorithms(input.algorithms);
|
||||
let cameraViewUrl: string | undefined;
|
||||
try {
|
||||
cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl);
|
||||
} catch {
|
||||
throw new BadRequestException('VDO.Ninja 시청 URL에 올바른 room과 view를 지정해주세요.');
|
||||
}
|
||||
|
||||
const platform = await this.prisma.instructorPlatform.findFirst({
|
||||
where: {
|
||||
@@ -2235,6 +2245,7 @@ export class InstructorService {
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
studioSettingsOverride: true,
|
||||
},
|
||||
});
|
||||
if (!platform) throw new NotFoundException('Platform not found');
|
||||
@@ -2253,6 +2264,14 @@ export class InstructorService {
|
||||
algorithms,
|
||||
controlDurationMinutes,
|
||||
controlWarningMinutes,
|
||||
...((cameraViewUrl !== undefined || input.cameraPassword !== undefined) ? {
|
||||
studioSettingsOverride: {
|
||||
...(platform.studioSettingsOverride && typeof platform.studioSettingsOverride === 'object' && !Array.isArray(platform.studioSettingsOverride)
|
||||
? platform.studioSettingsOverride : {}),
|
||||
...(cameraViewUrl !== undefined ? { cameraViewUrl } : {}),
|
||||
...(cameraViewUrl === '' ? { cameraPassword: '' } : input.cameraPassword !== undefined ? { cameraPassword: input.cameraPassword } : {}),
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
}),
|
||||
this.prisma.studentGroup.updateMany({
|
||||
@@ -3692,6 +3711,7 @@ export class InstructorService {
|
||||
environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] },
|
||||
maps: { orderBy: { key: 'asc' } },
|
||||
weekMaps: { orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }] },
|
||||
instructorCurriculumWeekOverrides: true,
|
||||
},
|
||||
});
|
||||
if (!template) {
|
||||
@@ -3743,6 +3763,28 @@ export class InstructorService {
|
||||
tx.courseWeekReference.createMany({ data: sourceWeek.references.map((reference) => ({ weekId: week.id, kind: reference.kind, title: reference.title, originalFileName: reference.originalFileName, storedFileName: reference.storedFileName, contentType: reference.contentType, fileSizeBytes: reference.fileSizeBytes, sortOrder: reference.sortOrder })) }),
|
||||
]);
|
||||
|
||||
const learningOverride = template.instructorCurriculumWeekOverrides.find(
|
||||
(override) => override.week === sourceWeek.week && override.organizationName === template.organizationName,
|
||||
);
|
||||
if (learningOverride?.learningSteps) {
|
||||
const learningSteps = this.resolveLearningSteps(
|
||||
learningOverride.learningSteps,
|
||||
sourceWeek.topics.map((topic) => topic.title),
|
||||
sourceWeek.topics.map((topic) => topic.detail),
|
||||
);
|
||||
await tx.instructorCurriculumWeekOverride.create({
|
||||
data: {
|
||||
courseId: course.id,
|
||||
organizationName,
|
||||
week: week.week,
|
||||
learningSteps,
|
||||
topics: learningSteps.map((step) => step.title),
|
||||
topicDetails: learningSteps.map((step) => step.description),
|
||||
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const sourceModule of sourceWeek.modules) {
|
||||
const module = await tx.courseWeekModule.create({
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
|
||||
|
||||
describe('platform camera settings', () => {
|
||||
it('keeps only viewing parameters', () => {
|
||||
const url = new URL(normalizeCameraUrl('https://vdo.ninja/?room=track&view=camera&push=bad&director=bad&password=secret'));
|
||||
expect(url.searchParams.get('room')).toBe('track');
|
||||
expect(url.searchParams.get('view')).toBe('camera');
|
||||
expect(url.searchParams.has('scene')).toBe(true);
|
||||
expect(url.searchParams.has('push')).toBe(false);
|
||||
expect(url.searchParams.has('director')).toBe(false);
|
||||
expect(url.searchParams.has('password')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['http://vdo.ninja/?room=x&view=y', 'https://example.com/?room=x&view=y', 'https://vdo.ninja/?room=x', 'javascript:alert(1)', 'https://vdo.ninja:8443/?room=x&view=y'])('rejects unsafe or incomplete URL %s', (url) => {
|
||||
expect(() => normalizeCameraUrl(url)).toThrow();
|
||||
});
|
||||
|
||||
it('supports unset camera settings and preserves password whitespace', () => {
|
||||
expect(normalizeCameraUrl(' ')).toBe('');
|
||||
expect(readPlatformCamera(null)).toEqual({ url: '', password: '' });
|
||||
expect(readPlatformCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export function readPlatformCamera(settings: unknown): { url: string; password: string } {
|
||||
const value = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {};
|
||||
return {
|
||||
url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '',
|
||||
password: typeof value.cameraPassword === 'string' ? value.cameraPassword : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCameraUrl(input: string): string {
|
||||
if (!input.trim()) return '';
|
||||
const url = new URL(input.trim());
|
||||
if (url.protocol !== 'https:' || url.hostname !== 'vdo.ninja' || url.port || url.username || url.password || url.pathname !== '/') {
|
||||
throw new Error('VDO.Ninja 시청 URL을 입력해주세요.');
|
||||
}
|
||||
if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) {
|
||||
throw new Error('시청 URL에 room과 view가 필요합니다.');
|
||||
}
|
||||
// Only viewer parameters are accepted; never retain publishing/director options.
|
||||
const view = new URL('https://vdo.ninja/');
|
||||
view.searchParams.set('scene', '');
|
||||
view.searchParams.set('room', url.searchParams.get('room')!);
|
||||
view.searchParams.set('view', url.searchParams.get('view')!);
|
||||
return view.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user