feat: add configurable VDO.Ninja overview and floating window focus
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "a67d74b2-bfd7-4869-8a39-1ebdeccf0bb3",
|
||||||
|
"organizationId": "b545a634-e877-4935-8041-c83e352b1fb7",
|
||||||
|
"organizationName": "퓨처드라이브",
|
||||||
|
"courseId": "8e296f81-8445-455c-b1a2-47f2ce9099a4",
|
||||||
|
"week": 4,
|
||||||
|
"tagLabel": "없음",
|
||||||
|
"title": "4주차 커리큘럼",
|
||||||
|
"duration": "-",
|
||||||
|
"overview": "",
|
||||||
|
"deliverable": "",
|
||||||
|
"requiresSourceSubmission": true,
|
||||||
|
"isTeamProject": false,
|
||||||
|
"learningSteps": [
|
||||||
|
{
|
||||||
|
"title": "학습단계 1",
|
||||||
|
"concepts": [],
|
||||||
|
"videoUrl": "",
|
||||||
|
"description": "",
|
||||||
|
"videoEnabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topics": [
|
||||||
|
"학습단계 1"
|
||||||
|
],
|
||||||
|
"topicDetails": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"topicVideoUrls": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"practiceContents": [],
|
||||||
|
"disabledModuleIds": [],
|
||||||
|
"createdAt": "2026-09-22T06:18:44.883Z",
|
||||||
|
"updatedAt": "2026-09-22T06:18:44.883Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
require('dotenv').config({ quiet: true });
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.hostname = '192.168.0.20';
|
||||||
|
url.port = '15437';
|
||||||
|
url.pathname = '/lms';
|
||||||
|
url.searchParams.set('connect_timeout', '5');
|
||||||
|
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const slug = 'futuredrive-autonomy-basic';
|
||||||
|
const localImage = 'future-nav2-gazebo:humble';
|
||||||
|
const remoteImage = 'future-nav2:humble-jetson_orin';
|
||||||
|
const names = ['NAV2-Control', 'NAV2-Localization', 'NAV2-Map', 'NAV2-Perception',
|
||||||
|
'NAV2-Perception3D', 'NAV2-Planning', 'NAV2-Gazebo', 'NAV2-SLAM', 'NAV2-SLAM3D',
|
||||||
|
'NAV2-MKMini', 'NAV2-Rviz2'];
|
||||||
|
const steps = [
|
||||||
|
{ title: 'SLAM', detail: '원거리 MK-mini 플랫폼에 연결하여 모듈 소스와 파라미터를 수정하고, 센서 토픽을 확인하며 SLAM 맵을 생성·저장합니다. 로컬 MK-mini 시뮬레이션에서 먼저 검증합니다.' },
|
||||||
|
{ title: '자율주행', detail: '저장한 맵으로 위치추정·경로 계획·제어 모듈을 구성하고 목표 지점 주행을 검증합니다. 원격 코드 편집과 빌드, 실행 로그 분석 및 파라미터 튜닝을 실습합니다.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parameters(module, remote) {
|
||||||
|
const sim = `sim_mode:=${!remote}`;
|
||||||
|
switch (module.name) {
|
||||||
|
case 'NAV2-Gazebo': return ['gui:=false', 'platform:=mkmini'];
|
||||||
|
case 'NAV2-MKMini': return [sim, 'platform:=mkmini', 'robot:=mkmini', 'level:=2d', ...(!remote ? ['simulator:=gazebo'] : [])];
|
||||||
|
case 'NAV2-Control': return [sim, 'robot:=mkmini', 'level:=2d'];
|
||||||
|
case 'NAV2-Perception': return [sim];
|
||||||
|
case 'NAV2-Perception3D':
|
||||||
|
case 'NAV2-SLAM3D': return [sim, 'robot:=mkmini'];
|
||||||
|
case 'NAV2-Localization': return [sim, 'robot:=mkmini', 'level:=2d', 'localization_mode:=amcl'];
|
||||||
|
case 'NAV2-SLAM':
|
||||||
|
case 'NAV2-Planning':
|
||||||
|
case 'NAV2-Map': return [sim, 'robot:=mkmini', 'level:=2d'];
|
||||||
|
default: return module.defaultParameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshot(client) {
|
||||||
|
return client.course.findUniqueOrThrow({ where: { slug }, include: {
|
||||||
|
weeks: { orderBy: { week: 'asc' }, include: {
|
||||||
|
modules: { orderBy: { sortOrder: 'asc' } }, topics: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
practices: { orderBy: { sortOrder: 'asc' } }, references: true,
|
||||||
|
} }, environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] },
|
||||||
|
weekMaps: true,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const before = await snapshot(db);
|
||||||
|
assert.equal(before.weeks.length, 2, 'Expected only weeks 1 and 2; refusing to overwrite');
|
||||||
|
const source = before.weeks.find(w => w.week === 1);
|
||||||
|
assert(source);
|
||||||
|
const selected = names.map(name => {
|
||||||
|
const matches = source.modules.filter(m => m.name === name);
|
||||||
|
assert.equal(matches.length, 1, `Missing or ambiguous module: ${name}`);
|
||||||
|
return matches[0];
|
||||||
|
});
|
||||||
|
const profiles = before.environmentProfiles.filter(p => p.week === 1);
|
||||||
|
assert.equal(profiles.length, 2);
|
||||||
|
assert(profiles.some(p => p.config.target === 'local'));
|
||||||
|
assert(profiles.some(p => p.config.target === 'remote-jetson'));
|
||||||
|
if (apply) {
|
||||||
|
const backup = path.resolve('backups', `base-before-week3-${Date.now()}.json`);
|
||||||
|
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
||||||
|
fs.writeFileSync(backup, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' });
|
||||||
|
console.log('Backup:', backup);
|
||||||
|
}
|
||||||
|
let summary;
|
||||||
|
try {
|
||||||
|
await db.$transaction(async tx => {
|
||||||
|
assert.deepEqual(await snapshot(tx), before, 'Source changed during preparation');
|
||||||
|
const week = await tx.courseWeek.create({ data: {
|
||||||
|
courseId: before.id, week: 3, title: '원격 개발', tags: ['MK-mini', '진행예정'],
|
||||||
|
progress: 0, state: 'locked', duration: '1주', github: source.github,
|
||||||
|
overview: '원거리 MK-mini 플랫폼을 연결하고 원격으로 코드를 편집·빌드·실행합니다. 로컬 MK-mini 시뮬레이션과 실차에서 SLAM 및 자율주행을 검증합니다.',
|
||||||
|
deliverable: 'SLAM 저장 맵, 자율주행 검증 결과, 원격 개발 소스 변경 내역 및 실행 로그를 제출합니다.',
|
||||||
|
requiresSourceSubmission: true,
|
||||||
|
topics: { create: steps.map((s, i) => ({ ...s, sortOrder: i + 1 })) },
|
||||||
|
practices: { create: steps.map((s, i) => ({ title: s.title, description: s.detail, sortOrder: i + 1 })) },
|
||||||
|
} });
|
||||||
|
const mapping = new Map();
|
||||||
|
const created = [];
|
||||||
|
for (const [i, m] of selected.entries()) {
|
||||||
|
const { id, weekId, createdAt, updatedAt, ...data } = m;
|
||||||
|
const next = await tx.courseWeekModule.create({ data: {
|
||||||
|
...data, weekId: week.id, sortOrder: i + 1,
|
||||||
|
dockerImage: localImage, defaultParameters: parameters(m, false),
|
||||||
|
} });
|
||||||
|
mapping.set(id, next.id);
|
||||||
|
created.push(next);
|
||||||
|
}
|
||||||
|
const newProfiles = [];
|
||||||
|
for (const p of profiles) {
|
||||||
|
const remote = p.config.target === 'remote-jetson';
|
||||||
|
const config = structuredClone(p.config);
|
||||||
|
config.moduleSet.modules = p.config.moduleSet.modules.flatMap(entry => {
|
||||||
|
if (!mapping.has(entry.id)) return [];
|
||||||
|
const m = selected.find(m => m.id === entry.id);
|
||||||
|
return [{ ...entry, id: mapping.get(entry.id), dockerImage: remote ? remoteImage : localImage,
|
||||||
|
defaultParameters: parameters(m, remote) }];
|
||||||
|
});
|
||||||
|
config.moduleSet.includeUnlisted = false;
|
||||||
|
assert.equal(config.moduleSet.modules.length, 9);
|
||||||
|
assert(config.moduleSet.modules.every(m => created.some(c => c.id === m.id)));
|
||||||
|
newProfiles.push(await tx.courseEnvironmentProfile.create({ data: {
|
||||||
|
courseId: before.id, week: 3, platformKey: p.platformKey, profileType: p.profileType,
|
||||||
|
name: p.name, enabled: p.enabled, config,
|
||||||
|
} }));
|
||||||
|
}
|
||||||
|
for (const m of before.weekMaps.filter(m => m.week === 1)) {
|
||||||
|
await tx.courseWeekMap.create({ data: { courseId: before.id, week: 3, mapKey: m.mapKey, sortOrder: m.sortOrder } });
|
||||||
|
}
|
||||||
|
const after = await snapshot(tx);
|
||||||
|
assert.deepEqual(after.weeks.filter(w => w.week !== 3), before.weeks);
|
||||||
|
assert.deepEqual(after.environmentProfiles.filter(p => p.week !== 3), before.environmentProfiles);
|
||||||
|
assert.equal(after.weeks.find(w => w.week === 3).topics.length, 2);
|
||||||
|
assert(!created.some(m => /yahboom|turtlebot/i.test(JSON.stringify(m))));
|
||||||
|
summary = { applied: apply, course: before.title, weekId: week.id, title: week.title,
|
||||||
|
steps: steps.map(s => s.title), modules: created.map(m => m.name),
|
||||||
|
environments: newProfiles.map(p => ({ target: p.config.target, modules: p.config.moduleSet.modules.map(m => ({
|
||||||
|
name: created.find(c => c.id === m.id).name, image: m.dockerImage, parameters: m.defaultParameters,
|
||||||
|
})) })) };
|
||||||
|
if (!apply) throw new Error('DRY_RUN_ROLLBACK');
|
||||||
|
}, { isolationLevel: 'Serializable', timeout: 30000 });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message !== 'DRY_RUN_ROLLBACK') throw e;
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
require('dotenv').config({ quiet: true });
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.hostname = '192.168.0.20';
|
||||||
|
url.port = '15437';
|
||||||
|
url.pathname = '/lms';
|
||||||
|
url.searchParams.set('connect_timeout', '5');
|
||||||
|
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const include = {
|
||||||
|
weeks: { orderBy: { week: 'asc' }, include: {
|
||||||
|
modules: { orderBy: { sortOrder: 'asc' } }, topics: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
practices: { orderBy: { sortOrder: 'asc' } }, references: { orderBy: { id: 'asc' } },
|
||||||
|
} },
|
||||||
|
environmentProfiles: { orderBy: { id: 'asc' } }, maps: { orderBy: { id: 'asc' } },
|
||||||
|
weekMaps: { orderBy: { id: 'asc' } }, instructorCurriculumWeekOverrides: { orderBy: { id: 'asc' } },
|
||||||
|
};
|
||||||
|
const snapshot = client => client.course.findMany({ orderBy: { id: 'asc' }, include });
|
||||||
|
function data(row) {
|
||||||
|
const { id, createdAt, updatedAt, ...rest } = row;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const before = await snapshot(db);
|
||||||
|
const base = before.find(c => c.slug === 'futuredrive-autonomy-basic');
|
||||||
|
assert(base);
|
||||||
|
const source = base.weeks.find(w => w.week === 3);
|
||||||
|
assert.equal(source?.title, '원격 개발');
|
||||||
|
assert.equal(source.topics.length, 2);
|
||||||
|
assert.equal(source.modules.length, 11);
|
||||||
|
const profiles = base.environmentProfiles.filter(p => p.week === 3);
|
||||||
|
assert.equal(profiles.length, 2);
|
||||||
|
const targets = before.filter(c => c.id !== base.id
|
||||||
|
&& c.slug !== 'autonomy-studio-lms-guide'
|
||||||
|
&& !c.weeks.some(w => w.week === 3));
|
||||||
|
for (const c of targets) {
|
||||||
|
assert(!c.environmentProfiles.some(p => p.week === 3), `${c.title}: existing profile`);
|
||||||
|
assert(!c.weekMaps.some(p => p.week === 3), `${c.title}: existing map assignments`);
|
||||||
|
assert(!c.instructorCurriculumWeekOverrides.some(p => p.week === 3), `${c.title}: existing override`);
|
||||||
|
}
|
||||||
|
if (apply) {
|
||||||
|
fs.mkdirSync('backups', { recursive: true });
|
||||||
|
const file = `backups/courses-before-remote-week-${Date.now()}.json`;
|
||||||
|
fs.writeFileSync(file, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' });
|
||||||
|
console.log('Backup:', file);
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
try {
|
||||||
|
await db.$transaction(async tx => {
|
||||||
|
assert.deepEqual(await snapshot(tx), before);
|
||||||
|
for (const c of targets) {
|
||||||
|
const { modules, topics, practices, references, ...weekData } = data(source);
|
||||||
|
const week = await tx.courseWeek.create({ data: { ...weekData, courseId: c.id } });
|
||||||
|
for (const topic of topics) await tx.courseWeekTopic.create({ data: { ...data(topic), weekId: week.id } });
|
||||||
|
for (const practice of practices) await tx.courseWeekPractice.create({ data: { ...data(practice), weekId: week.id } });
|
||||||
|
for (const reference of references) await tx.courseWeekReference.create({ data: { ...data(reference), weekId: week.id } });
|
||||||
|
const ids = new Map();
|
||||||
|
for (const module of modules) {
|
||||||
|
const created = await tx.courseWeekModule.create({ data: { ...data(module), weekId: week.id } });
|
||||||
|
ids.set(module.id, created.id);
|
||||||
|
}
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const config = structuredClone(profile.config);
|
||||||
|
config.moduleSet.modules = config.moduleSet.modules.map(m => {
|
||||||
|
assert(ids.has(m.id), 'Cross-week module reference');
|
||||||
|
return { ...m, id: ids.get(m.id) };
|
||||||
|
});
|
||||||
|
await tx.courseEnvironmentProfile.create({ data: { ...data(profile), courseId: c.id, config } });
|
||||||
|
}
|
||||||
|
for (const assignment of base.weekMaps.filter(m => m.week === 3)) {
|
||||||
|
const systemMap = ['nav2-turtlebot3-world', 'nav2-turtlebot3-house'].includes(assignment.mapKey);
|
||||||
|
if (!systemMap && !c.maps.some(m => m.key === assignment.mapKey)) {
|
||||||
|
const map = base.maps.find(m => m.key === assignment.mapKey);
|
||||||
|
assert(map, 'Missing source map');
|
||||||
|
await tx.courseMap.create({ data: { ...data(map), courseId: c.id } });
|
||||||
|
}
|
||||||
|
await tx.courseWeekMap.create({ data: { ...data(assignment), courseId: c.id } });
|
||||||
|
}
|
||||||
|
summary.push({ course: c.title, week: 3, weekId: week.id, modules: ids.size });
|
||||||
|
}
|
||||||
|
const after = await snapshot(tx);
|
||||||
|
for (const old of before) {
|
||||||
|
const current = after.find(c => c.id === old.id);
|
||||||
|
if (!targets.some(c => c.id === old.id)) { assert.deepEqual(current, old); continue; }
|
||||||
|
assert.deepEqual(current.weeks.filter(w => w.week !== 3), old.weeks);
|
||||||
|
assert.deepEqual(current.environmentProfiles.filter(p => p.week !== 3), old.environmentProfiles);
|
||||||
|
assert.deepEqual(current.weekMaps.filter(m => m.week !== 3), old.weekMaps);
|
||||||
|
assert.deepEqual(current.instructorCurriculumWeekOverrides, old.instructorCurriculumWeekOverrides);
|
||||||
|
for (const map of old.maps) assert.deepEqual(current.maps.find(m => m.id === map.id), map);
|
||||||
|
const added = current.weeks.find(w => w.week === 3);
|
||||||
|
assert.equal(added.topics.length, 2);
|
||||||
|
assert.equal(added.modules.length, 11);
|
||||||
|
const envs = current.environmentProfiles.filter(p => p.week === 3);
|
||||||
|
assert.equal(envs.length, 2);
|
||||||
|
for (const p of envs) {
|
||||||
|
assert.equal(p.config.moduleSet.modules.length, 9);
|
||||||
|
assert(p.config.moduleSet.modules.every(m => added.modules.some(n => n.id === m.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!apply) throw new Error('DRY_RUN_ROLLBACK');
|
||||||
|
}, { isolationLevel: 'Serializable', timeout: 30000 });
|
||||||
|
} catch (e) { if (e.message !== 'DRY_RUN_ROLLBACK') throw e; }
|
||||||
|
console.log(JSON.stringify({ applied: apply, courses: summary }, null, 2));
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
require('dotenv').config({ quiet: true });
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.hostname = '192.168.0.20'; url.port = '15437'; url.pathname = '/lms';
|
||||||
|
url.searchParams.set('connect_timeout', '5');
|
||||||
|
const db = new PrismaClient({ datasources: { db: { url: url.toString() } } });
|
||||||
|
const lessons = {
|
||||||
|
1: [
|
||||||
|
['ROS2 기초', '노드와 패키지 구조를 이해하고 Topic, Service, Action으로 데이터를 주고받습니다. CLI로 통신 상태를 확인하고 TF를 통해 센서와 차량 좌표계의 관계를 살펴봅니다.', ['노드와 패키지', 'Topic · Service · Action', 'TF 좌표계']],
|
||||||
|
['Sensor Processing', 'LiDAR와 카메라 데이터의 형식, 갱신 주기와 좌표계를 확인합니다. 노이즈와 불필요한 데이터를 걸러내고 센서 보정 결과가 인식에 미치는 영향을 비교합니다.', ['센서 메시지와 타임스탬프', '필터링과 전처리', '센서 캘리브레이션']],
|
||||||
|
['Simulation 환경 구축 Part 1', 'Gazebo 월드와 차량 모듈을 실행하고 센서 데이터가 ROS2 토픽으로 전달되는 과정을 확인합니다. 시뮬레이션 시간과 TF 연결을 점검해 실습의 기본 환경을 구성합니다.', ['Gazebo 월드와 로봇 모델', '시뮬레이션 시간', '센서 토픽과 TF 연결']],
|
||||||
|
['Simulation 환경 구축 Part 2', '실습 차량에 맞는 모듈과 파라미터를 선택하고 실행 순서를 정리합니다. 차량 모델을 바꾸며 공통 NAV2 모듈과 차량별 인터페이스의 역할을 구분합니다.', ['환경별 모듈 구성', '차량 인터페이스', '실행 순서와 의존성']],
|
||||||
|
['LiDAR SLAM', '차량을 이동시키며 LiDAR 관측과 오도메트리를 이용해 2D 맵을 생성합니다. 반복 구간과 이동 속도에 따른 맵 품질을 비교하고, 완성한 맵을 저장해 다시 확인합니다.', ['점유격자 지도', '오도메트리와 스캔 정합', '루프 클로저']],
|
||||||
|
['LiDAR Localization', '저장한 맵을 불러오고 초기 위치를 지정해 AMCL 위치추정을 시작합니다. 이동 중 추정 위치와 센서 관측이 일치하는지 확인하고 초기화가 잘못된 경우의 증상을 살펴봅니다.', ['AMCL 입자 필터', '초기 위치 설정', 'map · odom · base_link']],
|
||||||
|
['Parameter Tuning', '센서 처리와 NAV2 모듈의 주요 파라미터를 한 번에 하나씩 변경합니다. 같은 주행 조건에서 경로, 오차와 처리 시간을 비교하고 설정값과 결과를 기록합니다.', ['기준 시나리오', '단일 변수 비교', '성능 지표와 재현성']],
|
||||||
|
['[심화] Camera based SLAM', '카메라 기반 SLAM의 영상 입력과 위치·맵 출력 구조를 살펴봅니다. 특징점 추적과 깊이 정보의 역할을 이해하고 조명 변화나 반복 무늬에서 발생하는 한계를 분석합니다.', ['영상 특징점 추적', '깊이 정보와 스케일', '시각 오도메트리']],
|
||||||
|
['Occupancy Map', '센서 관측이 점유격자와 costmap에 반영되는 흐름을 확인합니다. 장애물 주변의 비용과 차량 크기 설정을 바꾸며 통과 가능한 공간과 안전 여유가 달라지는 모습을 비교합니다.', ['점유 확률', 'Costmap 레이어', 'Footprint와 Inflation']],
|
||||||
|
['Planning', '시작 위치와 목표 지점을 설정해 전역 경로 생성 과정을 확인합니다. 장애물 배치와 주행 제약을 바꾸며 경로의 길이, 안전성 및 재계획 결과를 비교합니다.', ['전역 경로 탐색', '장애물 비용과 주행 제약', '경로 재계획']],
|
||||||
|
['Control', '계획된 경로를 따라가도록 속도 명령을 생성하는 제어 흐름을 확인합니다. 속도와 회전 관련 설정을 조절하고 곡선 및 좁은 구간에서 추종 오차와 안정성을 비교합니다.', ['경로 추종', '속도·회전 제약', '추종 오차와 안정성']],
|
||||||
|
['[심화] 딥러닝 기반 객체 인식', '카메라 영상에서 객체를 검출하고 클래스, 위치와 신뢰도 결과를 해석합니다. 인식 결과를 ROS2 데이터 흐름에 연결하고 오검출과 추론 지연이 주행 판단에 미치는 영향을 살펴봅니다.', ['객체 검출과 신뢰도', '오검출·미검출', '추론 지연과 ROS2 연동']],
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
['프로젝트 주제와 역할 분담', '팀이 해결할 자율주행 문제를 정의하고 실습 가능한 시나리오로 구체화합니다. 성공 기준과 역할, 일정을 정하고 공통으로 기록할 검증 항목을 합의합니다.', ['문제 정의와 요구사항', '역할과 인터페이스 분담', '성공 기준']],
|
||||||
|
['센서·모듈 구성 설계', '시나리오에 필요한 센서와 NAV2 모듈을 선택하고 입력·출력 토픽 및 TF 관계를 정리합니다. 로컬 시뮬레이션과 실차의 차이를 구분해 실행 순서와 파라미터를 설계합니다.', ['센서·모듈 데이터 흐름', '토픽과 TF 인터페이스', '환경별 실행 구성']],
|
||||||
|
['알고리즘 구현과 튜닝', 'SLAM, 위치추정, 경로 계획과 제어 중 개선할 부분을 정해 소스나 파라미터를 수정합니다. 변경 전후를 같은 조건에서 비교하고 팀원이 재현할 수 있도록 설정과 결과를 공유합니다.', ['소스 변경 관리', '파라미터 튜닝', '비교 실험']],
|
||||||
|
['시뮬레이션·플랫폼 검증', '시뮬레이션에서 기능을 확인한 뒤 안전한 실차 환경에서 동일한 시나리오를 검증합니다. 토픽, 로그와 주행 결과를 함께 분석해 환경 차이와 실패 원인을 정리합니다.', ['단계별 통합 검증', '시뮬레이션과 실차 차이', '로그 기반 원인 분석']],
|
||||||
|
['결과 제출과 발표', '문제 정의부터 설계, 구현과 검증까지의 과정을 소스 및 실행 결과와 함께 정리합니다. 성능 개선 근거와 남은 한계를 설명하고 다른 팀이 실행할 수 있는 절차를 제공합니다.', ['재현 가능한 실행 절차', '정량·정성 평가', '결과와 한계 전달']],
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
['SLAM', '원거리 MK-mini 플랫폼에 연결해 센서 토픽과 TF 상태를 점검하고 원격으로 모듈을 편집·빌드·실행합니다. 로컬 MK-mini 시뮬레이션에서 먼저 확인한 뒤 실차에서 SLAM 맵을 생성·저장하고 통신 지연과 맵 품질을 비교합니다.', ['원격 개발·실행 흐름', '센서 토픽과 TF 점검', 'SLAM 맵 생성·저장']],
|
||||||
|
['자율주행', '저장한 맵으로 MK-mini의 위치추정, 경로 계획과 제어 모듈을 구성하고 초기 위치와 목표 지점을 설정합니다. 원격으로 로그와 파라미터를 확인하며 주행 결과를 개선하고, 통신 단절에 대비한 정지·복구 절차를 점검합니다.', ['위치추정·계획·제어 연동', '원격 튜닝과 로그 분석', '통신 지연과 안전한 복구']],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const include = { weeks: { orderBy: { week: 'asc' }, include: { topics: { orderBy: { sortOrder: 'asc' } }, modules: { orderBy: { id: 'asc' } }, practices: true } }, instructorCurriculumWeekOverrides: { orderBy: { id: 'asc' } }, environmentProfiles: { orderBy: { id: 'asc' } } };
|
||||||
|
const snapshot = tx => tx.course.findMany({ orderBy: { id: 'asc' }, include });
|
||||||
|
async function main() {
|
||||||
|
const before = await snapshot(db);
|
||||||
|
const targets = before.filter(c => c.slug !== 'autonomy-studio-lms-guide');
|
||||||
|
assert.equal(targets.length, 3);
|
||||||
|
for (const c of targets) for (const week of [1, 2, 3]) {
|
||||||
|
const w = c.weeks.find(w => w.week === week); assert(w);
|
||||||
|
assert.deepEqual(w.topics.map(t => t.title), lessons[week].map(l => l[0]));
|
||||||
|
}
|
||||||
|
const backup = `backups/learning-steps-before-${Date.now()}.json`;
|
||||||
|
fs.mkdirSync('backups', { recursive: true });
|
||||||
|
fs.writeFileSync(backup, JSON.stringify(before, null, 2), { mode: 0o600, flag: 'wx' });
|
||||||
|
console.log('Backup:', backup);
|
||||||
|
await db.$transaction(async tx => {
|
||||||
|
assert.deepEqual(await snapshot(tx), before);
|
||||||
|
for (const c of targets) for (const week of [1, 2, 3]) {
|
||||||
|
const w = c.weeks.find(w => w.week === week);
|
||||||
|
for (const [i, topic] of w.topics.entries()) await tx.courseWeekTopic.update({ where: { id: topic.id }, data: { detail: lessons[week][i][1] } });
|
||||||
|
const existing = c.instructorCurriculumWeekOverrides.filter(o => o.week === week);
|
||||||
|
const overrides = existing.length ? existing : [{ organizationName: c.organizationName }];
|
||||||
|
for (const o of overrides) {
|
||||||
|
const steps = lessons[week].map(([title, description, concepts], i) => {
|
||||||
|
const old = o.learningSteps?.[i];
|
||||||
|
if (old) assert.equal(old.title, title);
|
||||||
|
const videoUrl = old?.videoUrl ?? o.topicVideoUrls?.[i] ?? '';
|
||||||
|
return { ...old, title, description, concepts, videoUrl, videoEnabled: old?.videoEnabled ?? Boolean(videoUrl) };
|
||||||
|
});
|
||||||
|
const data = { learningSteps: steps, topics: steps.map(s => s.title), topicDetails: steps.map(s => s.description) };
|
||||||
|
if (o.id) await tx.instructorCurriculumWeekOverride.update({ where: { id: o.id }, data });
|
||||||
|
else await tx.instructorCurriculumWeekOverride.create({ data: { courseId: c.id, week, organizationName: c.organizationName, ...data } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const after = await snapshot(tx);
|
||||||
|
assert.deepEqual(after.find(c => c.slug === 'autonomy-studio-lms-guide'), before.find(c => c.slug === 'autonomy-studio-lms-guide'));
|
||||||
|
for (const old of targets) {
|
||||||
|
const current = after.find(c => c.id === old.id);
|
||||||
|
assert.deepEqual(current.environmentProfiles, old.environmentProfiles);
|
||||||
|
for (const w of current.weeks) {
|
||||||
|
const prev = old.weeks.find(p => p.id === w.id);
|
||||||
|
assert.deepEqual(w.modules, prev.modules);
|
||||||
|
assert.deepEqual(w.practices, prev.practices);
|
||||||
|
}
|
||||||
|
for (const o of current.instructorCurriculumWeekOverrides.filter(o => [1, 2, 3].includes(o.week))) {
|
||||||
|
assert.deepEqual(o.learningSteps.map(s => [s.title, s.description, s.concepts]), lessons[o.week]);
|
||||||
|
const prev = old.instructorCurriculumWeekOverrides.find(p => p.id === o.id);
|
||||||
|
if (prev) {
|
||||||
|
const omit = v => { const { learningSteps, topics, topicDetails, updatedAt, ...rest } = v; return rest; };
|
||||||
|
assert.deepEqual(omit(o), omit(prev));
|
||||||
|
if (prev.learningSteps) assert.deepEqual(o.learningSteps.map(s => [s.videoUrl, s.videoEnabled]), prev.learningSteps.map(s => [s.videoUrl, s.videoEnabled]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { isolationLevel: 'Serializable', timeout: 30000 });
|
||||||
|
console.log('Updated 3 courses, 9 weeks, 57 learning steps; 3 concepts per step.');
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.$disconnect());
|
||||||
@@ -221,7 +221,7 @@ CROSS JOIN (
|
|||||||
),
|
),
|
||||||
'runtimeImages', jsonb_build_array(
|
'runtimeImages', jsonb_build_array(
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin/latest/slam-ros-bridge-jetson-orin.tar.zst?v=0659d4cf5578',
|
'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst',
|
||||||
'image', 'slam-ros-bridge:jetson_orin',
|
'image', 'slam-ros-bridge:jetson_orin',
|
||||||
'fileName', 'slam-ros-bridge-jetson-orin.tar.zst',
|
'fileName', 'slam-ros-bridge-jetson-orin.tar.zst',
|
||||||
'required', true,
|
'required', true,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Update only the legacy Jetson bridge URL; preserve array order and all other settings.
|
||||||
|
WITH replacement AS (
|
||||||
|
SELECT
|
||||||
|
'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin/latest/slam-ros-bridge-jetson-orin.tar.zst'::text AS old_url,
|
||||||
|
'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst'::text AS new_url
|
||||||
|
), updated_profiles AS (
|
||||||
|
SELECT profile.id,
|
||||||
|
jsonb_agg(
|
||||||
|
CASE WHEN artifact->>'image' = 'slam-ros-bridge:jetson_orin'
|
||||||
|
AND split_part(artifact->>'url', '?', 1) = replacement.old_url
|
||||||
|
THEN jsonb_set(artifact, '{url}', to_jsonb(replacement.new_url))
|
||||||
|
ELSE artifact END
|
||||||
|
ORDER BY ordinal
|
||||||
|
) AS images
|
||||||
|
FROM "course_environment_profiles" profile
|
||||||
|
CROSS JOIN replacement
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
CASE WHEN jsonb_typeof(profile.config::jsonb->'runtimeImages') = 'array'
|
||||||
|
THEN profile.config::jsonb->'runtimeImages' ELSE '[]'::jsonb END
|
||||||
|
) WITH ORDINALITY AS entries(artifact, ordinal)
|
||||||
|
WHERE profile.config::jsonb->>'target' = 'remote-jetson'
|
||||||
|
GROUP BY profile.id
|
||||||
|
HAVING bool_or(artifact->>'image' = 'slam-ros-bridge:jetson_orin'
|
||||||
|
AND split_part(artifact->>'url', '?', 1) = replacement.old_url)
|
||||||
|
)
|
||||||
|
UPDATE "course_environment_profiles" profile
|
||||||
|
SET config = jsonb_set(profile.config::jsonb, '{runtimeImages}', updated.images),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM updated_profiles updated
|
||||||
|
WHERE profile.id = updated.id
|
||||||
|
RETURNING profile.id, profile.config::jsonb->'runtimeImages' AS runtime_images;
|
||||||
@@ -7,6 +7,7 @@ import { PlatformControlService } from '../platform-control/platform-control.ser
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||||
import { serializeCourseMaps } from './course-maps';
|
import { serializeCourseMaps } from './course-maps';
|
||||||
|
import { readPlatformCamera } from '../instructor/platform-camera';
|
||||||
|
|
||||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||||
@@ -1099,6 +1100,7 @@ export class CurriculumService {
|
|||||||
organizationName: platform.organizationName,
|
organizationName: platform.organizationName,
|
||||||
iconKey: platform.platform.iconKey,
|
iconKey: platform.platform.iconKey,
|
||||||
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
|
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
|
||||||
|
camera: readPlatformCamera(platform.studioSettingsOverride),
|
||||||
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
|
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
|
||||||
platformKey: platform.platform.platformKey,
|
platformKey: platform.platform.platformKey,
|
||||||
vehicleType: platform.vehicleType,
|
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';
|
import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateInstructorPlatformDto {
|
export class UpdateInstructorPlatformDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cameraViewUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cameraPassword?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
||||||
|
|||||||
@@ -122,10 +122,12 @@ export class InstructorController {
|
|||||||
deleteCurriculumWeek(
|
deleteCurriculumWeek(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Param('week', ParseIntPipe) week: number,
|
@Param('week', ParseIntPipe) week: number,
|
||||||
|
@Query('courseId') courseId?: string,
|
||||||
) {
|
) {
|
||||||
return this.instructorService.deleteCurriculumWeek(
|
return this.instructorService.deleteCurriculumWeek(
|
||||||
this.getBearerToken(request),
|
this.getBearerToken(request),
|
||||||
week,
|
week,
|
||||||
|
courseId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dt
|
|||||||
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
||||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||||
import { groupCourseScope } from './group-course-scope';
|
import { groupCourseScope } from './group-course-scope';
|
||||||
|
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
|
||||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||||
@@ -1337,6 +1338,7 @@ export class InstructorService {
|
|||||||
type: this.toPlatformType(platform.platform.displayName),
|
type: this.toPlatformType(platform.platform.displayName),
|
||||||
status: activeControl ? '활성' : '대기',
|
status: activeControl ? '활성' : '대기',
|
||||||
battery: platform.battery,
|
battery: platform.battery,
|
||||||
|
camera: readPlatformCamera(platform.studioSettingsOverride),
|
||||||
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
|
||||||
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
|
||||||
: this.formatUptime(platform.uptimeMinutes),
|
: this.formatUptime(platform.uptimeMinutes),
|
||||||
@@ -1462,6 +1464,7 @@ export class InstructorService {
|
|||||||
return {
|
return {
|
||||||
week: week.week,
|
week: week.week,
|
||||||
title: override?.title ?? week.title,
|
title: override?.title ?? week.title,
|
||||||
|
canDelete: false,
|
||||||
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
|
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
|
||||||
progress: progressState.progress,
|
progress: progressState.progress,
|
||||||
state: progressState.state,
|
state: progressState.state,
|
||||||
@@ -1516,6 +1519,7 @@ export class InstructorService {
|
|||||||
return {
|
return {
|
||||||
week: override.week,
|
week: override.week,
|
||||||
title: override.title ?? `${override.week}주차 커리큘럼`,
|
title: override.title ?? `${override.week}주차 커리큘럼`,
|
||||||
|
canDelete: true,
|
||||||
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
|
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
|
||||||
progress: progressState.progress,
|
progress: progressState.progress,
|
||||||
state: progressState.state,
|
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 { authUser, instructor } = await this.getInstructor(accessToken);
|
||||||
const organizationId = authUser.primaryAffiliation?.organizationId;
|
const organizationId = authUser.primaryAffiliation?.organizationId;
|
||||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||||
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
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');
|
if (!course) throw new NotFoundException('Course not found');
|
||||||
|
|
||||||
const baseWeek = course.weeks.find((courseWeek) => courseWeek.week === week);
|
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 maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90);
|
||||||
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
|
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
|
||||||
const algorithms = this.normalizeAlgorithms(input.algorithms);
|
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({
|
const platform = await this.prisma.instructorPlatform.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -2235,6 +2245,7 @@ export class InstructorService {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
|
studioSettingsOverride: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!platform) throw new NotFoundException('Platform not found');
|
if (!platform) throw new NotFoundException('Platform not found');
|
||||||
@@ -2253,6 +2264,14 @@ export class InstructorService {
|
|||||||
algorithms,
|
algorithms,
|
||||||
controlDurationMinutes,
|
controlDurationMinutes,
|
||||||
controlWarningMinutes,
|
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({
|
this.prisma.studentGroup.updateMany({
|
||||||
@@ -3692,6 +3711,7 @@ export class InstructorService {
|
|||||||
environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] },
|
environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] },
|
||||||
maps: { orderBy: { key: 'asc' } },
|
maps: { orderBy: { key: 'asc' } },
|
||||||
weekMaps: { orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }] },
|
weekMaps: { orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }] },
|
||||||
|
instructorCurriculumWeekOverrides: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!template) {
|
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 })) }),
|
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) {
|
for (const sourceModule of sourceWeek.modules) {
|
||||||
const module = await tx.courseWeekModule.create({
|
const module = await tx.courseWeekModule.create({
|
||||||
data: {
|
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