동영상, 오프닝 자막 관련 문항 처리 방식 개선

This commit is contained in:
2025-06-16 16:43:57 +09:00
parent 5c62e57cfb
commit 52c08a0a69
4 changed files with 128 additions and 54 deletions

View File

@@ -229,8 +229,9 @@
"desc": "value값의 키값(VID___)은 이펙트의 속성종류에 따라 변경되므로 채점기준표작성시 확인 필요" "desc": "value값의 키값(VID___)은 이펙트의 속성종류에 따라 변경되므로 채점기준표작성시 확인 필요"
}, },
"5": { "5": {
"ele": "//CRCUnitArr[@Name='{search}']/@Name", "ele": "//CROwneUnit[{index}]/CRCUnitArr/@Name",
"ele2": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr/@Name", "ele2": "//CRCUnitArr[@Name='{search}']/@Name",
"ele3": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr/@Name",
"type": "video", "type": "video",
"value": "모래 촉감 놀이", "value": "모래 촉감 놀이",
"search": "모래 촉감 놀이", "search": "모래 촉감 놀이",

View File

@@ -211,7 +211,7 @@
"media": "동영상.mp4", "media": "동영상.mp4",
"value": { "value": {
"start": "0", "start": "0",
"end": "360" "end": "380"
}, },
"point": 2, "point": 2,
"desc": "시작시간과 재생시간 정답값 입력, 3번문항은 '동영상.mp4' 클립의 길이를 확인하는 문항이므로 media는 수정할 필요가 없다." "desc": "시작시간과 재생시간 정답값 입력, 3번문항은 '동영상.mp4' 클립의 길이를 확인하는 문항이므로 media는 수정할 필요가 없다."
@@ -229,59 +229,56 @@
"desc": "value값의 키값(VID___)은 이펙트의 속성종류에 따라 변경되므로 채점기준표작성시 확인 필요" "desc": "value값의 키값(VID___)은 이펙트의 속성종류에 따라 변경되므로 채점기준표작성시 확인 필요"
}, },
"5": { "5": {
"ele": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr/@Name", "ele": "//CROwneUnit[{index}]/CRCUnitArr/@Name",
"ele2": "//CRCUnitArr[@Name='{search}']/@Name",
"type": "video",
"value": "청량하고 시원한 폭포",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "video.Text",
"value": "청량하고 시원한 폭포",
"point": 3 "point": 3
}, },
"6": { "6": {
"ele": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr//GCUnitPool[@Type='1']/GCUnit/@VID102", "ele": "//CROwneUnit[{index}]/CRCUnitArr//GCUnitPool[@Type='1']/GCUnit/@VID102",
"ele2": "//CRCUnitArr[@Name='{search}']//GCUnitPool[@Type='1']/GCUnit/@VID102",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "video", "type": "video.Text",
"value": "돋움체", "value": "돋움체",
"point": 2 "point": 2
}, },
"7": { "7": {
"ele": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr//GCUnitPool[@Type='1']/GCUnit/@VID101", "ele": "//CROwneUnit[{index}]/CRCUnitArr//GCUnitPool[@Type='1']/GCUnit/@VID101",
"ele2": "//CRCUnitArr[@Name='{search}']//GCUnitPool[@Type='1']/GCUnit/@VID101",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "video", "type": "video.Text",
"value": "120", "value": "120",
"point": 2 "point": 2
}, },
"8": { "8": {
"ele": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr//GCUnitPool/GCUnit[@Type='4']/@VID100", "ele": "//CROwneUnit[{index}]/CRCUnitArr//GCUnitPool/GCUnit[@Type='4']/@VID100",
"ele2": "//CRCUnitArr[@Name='{search}']//GCUnitPool/GCUnit[@Type='4']/@VID100",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "video", "type": "video.Text.Color",
"value": "-937955", "value": "1db0f1",
"point": 2 "point": 2,
"desc": "컬러값은 RGB로 입력한다, [대소문자, #]허용 (#FFFFFF, ffffff 두 값 모두 허용)"
}, },
"9": { "9": {
"ele": "//CROwneUnit[{subtitleIndex}]/CRCUnitArr/@*[name()='VID600' or name()='VID601']", "ele": "//CROwneUnit[{index}]/CRCUnitArr/@*[name()='VID600' or name()='VID601']",
"ele2": "//CRCUnitArr[@Name='{search}']/@*[name()='VID600' or name()='VID601']",
"type": "video",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "video.Location",
"value": [ "value": [
0.27395833, "0.27395833",
0.9222222 "0.9222222"
], ],
"point": 2 "point": 2,
"desc": "정답 파일의 자막 좌표를 기준으로 프로그램 내부적으로 0.1까지 오차를 허용한다"
}, },
"10": { "10": {
"ele": "{search}", "ele": "",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "videoStartTime", "type": "video.StartTime",
"value": 170, "value": 170,
"point": 2 "point": 2
}, },
"11": { "11": {
"ele": "{search}", "ele": "",
"search": "청량하고 시원한 폭포", "search": "청량하고 시원한 폭포",
"type": "videoLength", "type": "video.Length",
"value": 150, "value": 150,
"point": 2 "point": 2
}, },

View File

@@ -172,12 +172,24 @@ outputExcelFiles.forEach((outputFile, index) => {
// scoring.json 파일 내에 있는 type에 따라 비교하는 방식이 달라짐 // scoring.json 파일 내에 있는 type에 따라 비교하는 방식이 달라짐
// 채점 결과를 scoringResultList 배열에 저장 // 채점 결과를 scoringResultList 배열에 저장
function getGmepScore(gmepData, scoringJson, index) { function getGmepScore(gmepData, scoringJson, index) {
function compareAndScore(userAnswer, rightAnswer, point, key, scoringResult) { function compareAndScore(userAnswer, rightAnswer, point, key, scoringResult, tolerance = 0) {
let score = 0; let score = 0;
const isEqual = (typeof rightAnswer === "object" && typeof userAnswer === "object") let isEqual;
? JSON.stringify(userAnswer) === JSON.stringify(rightAnswer)
: userAnswer == rightAnswer; if (Array.isArray(rightAnswer) && Array.isArray(userAnswer)) {
// 배열 길이 같아야 비교 가능
if (rightAnswer.length === userAnswer.length) {
isEqual = rightAnswer.every((val, idx) => Math.abs(val - userAnswer[idx]) <= tolerance);
} else {
isEqual = false;
}
} else if (typeof rightAnswer === "object" && typeof userAnswer === "object") {
// 객체일 때는 기존 방식 유지 (원하면 별도 로직 추가 가능)
isEqual = JSON.stringify(userAnswer) === JSON.stringify(rightAnswer);
} else {
isEqual = userAnswer == rightAnswer;
}
if (isEqual) { if (isEqual) {
score = point; score = point;
@@ -192,6 +204,25 @@ function getGmepScore(gmepData, scoringJson, index) {
return score; return score;
} }
function convertColorToHex(colorValue) {
// 문자열이면 정수로 변환
const intValue = typeof colorValue === 'string' ? parseInt(colorValue, 10) : colorValue;
// 부호 없는 32비트 정수로 변환 → 8자리 16진수 문자열로
const hex = (intValue >>> 0).toString(16).padStart(8, '0');
// 하위 6자리 추출 (BGR 순서)
const bgr = hex.slice(2); // 예: "fff1b01d" → "f1b01d"
// BGR → RGB로 재배열
const b = bgr.slice(0, 2);
const g = bgr.slice(2, 4);
const r = bgr.slice(4, 6);
// RGB 순서로 합치고 소문자로 반환
return (r + g + b).toLowerCase(); // e.g., "fd5721"
}
const gmepXmlDoc = gmepData; const gmepXmlDoc = gmepData;
const scoringResult = {}; const scoringResult = {};
@@ -320,7 +351,7 @@ function getGmepScore(gmepData, scoringJson, index) {
const rightAnswer = scoringData[key].value; const rightAnswer = scoringData[key].value;
const point = scoringData[key].point; const point = scoringData[key].point;
const type = scoringData[key].type; const type = scoringData[key].type;
const search = scoringData[key].search; let search = scoringData[key].search;
const media = scoringData[key].media; const media = scoringData[key].media;
const videoStartTime = scoringData.videoStartTime; const videoStartTime = scoringData.videoStartTime;
const openingStartTime = scoringData.openingStartTime; const openingStartTime = scoringData.openingStartTime;
@@ -333,15 +364,18 @@ function getGmepScore(gmepData, scoringJson, index) {
const trackClipNode = getTrackClipNode(gmepXmlDoc, type, videoStartTime, openingStartTime); const trackClipNode = getTrackClipNode(gmepXmlDoc, type, videoStartTime, openingStartTime);
const subtitleIndex = trackClipNode ? parseInt(trackClipNode.getAttribute('ClipIndex'), 10) + 1 : null; const subtitleIndex = trackClipNode ? parseInt(trackClipNode.getAttribute('ClipIndex'), 10) + 1 : null;
const textClipIndex = getTextClipIndex(gmepXmlDoc, search); const textClipIndex = getTextClipIndex(gmepXmlDoc, search);
const typeToOrderMap = { // const typeToOrderMap = {
opening: 1, // opening: 1,
openingStartTime: 1, // openingStartTime: 1,
openingLength: 1, // openingLength: 1,
video: 2, // openingText: 1,
videoStartTime: 2, // video: 2,
videoLength: 2, // videoStartTime: 2,
}; // videoLength: 2,
const subtitleOrder = typeToOrderMap[type] ?? null; // videoText: 2,
// };
// const subtitleOrder = typeToOrderMap[type] ?? null;
const subtitleOrder = type.includes('opening') ? 1 : type.includes('video') ? 2 : null;
const startTime = type === 'video' ? videoStartTime : type === 'opening' ? openingStartTime : null; const startTime = type === 'video' ? videoStartTime : type === 'opening' ? openingStartTime : null;
let xpathList = [ele, ele2, ele3, existEle]; let xpathList = [ele, ele2, ele3, existEle];
@@ -367,7 +401,8 @@ function getGmepScore(gmepData, scoringJson, index) {
let result = findSimilarString(gmepXmlDoc, search, 0.8); let result = findSimilarString(gmepXmlDoc, search, 0.8);
if (result !== null) { if (result !== null) {
result = result.replace(/"/g, "'"); result = result.replace(/"/g, "'");
[ele, ele2, ele3, existEle] = [ele, ele2, ele3, existEle].map(e => e?.replace(/{search}/g, result)); search = result;
[ele, ele2, ele3, existEle] = [ele, ele2, ele3, existEle].map(e => e?.replace(/{search}/g, search));
} else { } else {
[ele, ele2, ele3] = [ele, ele2, ele3].map(e => e?.includes('{search}') ? null : e); [ele, ele2, ele3] = [ele, ele2, ele3].map(e => e?.includes('{search}') ? null : e);
} }
@@ -488,13 +523,21 @@ function getGmepScore(gmepData, scoringJson, index) {
} }
} }
else if (type === 'openingStartTime' || type === 'openingLength' // 자막관련 type검사하는 구문을 opening포함 video포함으로 변경
|| type === 'videoStartTime' || type === 'videoLength') { // 6/16(월)시작 지점
// 1. JSON파일 10,11번 처럼 변경
else if (type.includes('opening') || type.includes('video')) {
function toHexColor(value) {
}
// else if (type === 'openingStartTime' || type === 'openingLength'
// || type === 'videoStartTime' || type === 'videoLength') {
// 자막의 정보를 이용해 CROwneUnit의 인덱스를 구함 // 자막의 정보를 이용해 CROwneUnit의 인덱스를 구함
// 1. 텍스트 // 1. 텍스트
// 2. 순서 // 2. 순서
// 3. 시작시간 // 3. 시작시간
const indexByText = getClipIndexByText(ele); const indexByText = getClipIndexByText(search);
const indexByOrder = getClipIndexByOrder(subtitleOrder); const indexByOrder = getClipIndexByOrder(subtitleOrder);
if (type.includes('opening')) time = openingStartTime; if (type.includes('opening')) time = openingStartTime;
@@ -505,29 +548,62 @@ function getGmepScore(gmepData, scoringJson, index) {
// 1, 2, 3순으로 자막을 찾음 // 1, 2, 3순으로 자막을 찾음
const index = indexByText ?? indexByOrder ?? indexByStartTime; const index = indexByText ?? indexByOrder ?? indexByStartTime;
if (index != null) { if (index != null) {
// 자막 시작시간 // 자막 시작시간 [2-10] [2-28]
if (type.includes('StartTime')) { if (type.includes('StartTime')) {
const crtrackClipIndex = getCrtrackClipIndex(index) const crtrackClipIndex = getCrtrackClipIndex(index)
const startTimeList = getSubtitleStartTime(); const startTimeList = getSubtitleStartTime();
const startTime = startTimeList[crtrackClipIndex]; const startTime = startTimeList[crtrackClipIndex];
userAnswer = startTime;
}
// 자막 길이 userAnswer = startTime;
totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult);
}
// 자막 길이 [2-11] [2-29]
else if (type.includes('Length')) { else if (type.includes('Length')) {
const crtrackClipIndex = getCrtrackClipIndex(index) + 1 // XML 1-based index const crtrackClipIndex = getCrtrackClipIndex(index) + 1 // XML 1-based index
const clipLength = xpath.select1(`//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[${crtrackClipIndex}]/@Length`, gmepXmlDoc); const clipLength = xpath.select1(`//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[${crtrackClipIndex}]/@Length`, gmepXmlDoc);
userAnswer = parseInt(clipLength.value, 10); userAnswer = parseInt(clipLength.value, 10);
totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult);
}
// 자막 텍스트(글자, 폰트, 크기, 색상) [2-5, 6, 7, 8] [2-22, 23, 24, 25]
else if (type.includes('Text') || type.includes('Color')) {
const xmlIndex = index + 1 // XML 1-based index
const subtitleXpath = ele?.replace(/{index}/g, xmlIndex);
const subtitleResult = xpath.select1(subtitleXpath, gmepXmlDoc);
if (subtitleResult) {
if (type.includes('Color')) {
const hex = convertColorToHex(subtitleResult.value);
userAnswer = hex;
}
else {
userAnswer = subtitleResult.value;
}
} else {
userAnswer = null;
}
totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult);
}
// 자막 위치 [2-9] (화면 정가운데 아래)
// 정답 좌표를 기준으로
else if (type.includes('Location')) {
const xmlIndex = index + 1 // XML 1-based index
const subtitleXpath = ele?.replace(/{index}/g, xmlIndex);
const subtitleResult = xpath.select(subtitleXpath, gmepXmlDoc);
userAnswer = subtitleResult.map(r => r.value);
const errorRange = 0.1;
totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult, tolerance = errorRange);
} }
} }
else { else {
userAnswer = null; userAnswer = null;
} }
totalScore += compareAndScore(userAnswer, rightAnswer, point, key, scoringResult);
} }
else if (type == "color") { else if (type == "color") {
const result = xpath.select(ele, gmepXmlDoc); const result = xpath.select(ele, gmepXmlDoc);

View File

@@ -1 +1 @@
[{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Tracking']]/Effects/Item/Name/@value"},{"kind":2,"language":"xpath","value":"sum(//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[@ClipIndex=0]/preceding-sibling::CRTrackClip/@Length)"},{"kind":2,"language":"xpath","value":"//CROwneUnit/CRCUnitArr[@Name=\"아름다운 꽃 축제 (Happy Flower Festival)\"]/@Name"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item[EffectData/{option}]/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value\r\n"},{"kind":2,"language":"xpath","value":"//CRTransFilter[@ClipIndex=count(//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex=count(//CRClip[@Path='이미지3.jpg']/preceding-sibling::CRClip | //CRClip[@Type='11']/CRCUnitArr[@Path='이미지3.jpg']/../preceding-sibling::CRClip)][1]/preceding-sibling::CRTrackClip)][@Type='2']/@*[name()='ID' or name()='Range' or name()='Type']"},{"kind":2,"language":"xpath","value":"//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[1]/@Length"},{"kind":2,"language":"xpath","value":"//CRClipArr/CRClip[@Type='11']/CRCUnitArr/@Path | //CRClipArr/CRClip[not(@Type='11')]/@Path"}] [{"kind":2,"language":"xpath","value":"//Layer[Name[@value='Tracking']]/Effects/Item/Name/@value"},{"kind":2,"language":"xpath","value":"sum(//CRTrackList[@Name='텍스트' or @Name='비디오2']/CRTrackClip[@ClipIndex=0]/preceding-sibling::CRTrackClip/@Length)"},{"kind":2,"language":"xpath","value":"//CROwneUnit/CRCUnitArr[@Name=\"아름다운 꽃 축제 (Happy Flower Festival)\"]/@Name"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item[EffectData/{option}]/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value"},{"kind":2,"language":"xpath","value":"//Layer[Name[@value='{layer}']]/Effects/Item/Name/@value | //Layer[Name[@value='{layer}']]/Effects/Item/EffectData/{option}/@value\r\n"},{"kind":2,"language":"xpath","value":"//CRTransFilter[@ClipIndex=count(//CRTrackList[@Name='비디오1']/CRTrackClip[@ClipIndex=count(//CRClip[@Path='이미지3.jpg']/preceding-sibling::CRClip | //CRClip[@Type='11']/CRCUnitArr[@Path='이미지3.jpg']/../preceding-sibling::CRClip)][1]/preceding-sibling::CRTrackClip)][@Type='2']/@*[name()='ID' or name()='Range' or name()='Type']"},{"kind":2,"language":"xpath","value":"//CROwneUnit[1]/CRCUnitArr/@*[name()='VID600' or name()='VID601']"},{"kind":2,"language":"xpath","value":"//CRClipArr/CRClip[@Type='11']/CRCUnitArr/@Path | //CRClipArr/CRClip[not(@Type='11')]/@Path"}]