feat: edit moving source trajectories

This commit is contained in:
Codex
2026-08-28 16:04:55 +09:00
parent acb36bf15d
commit a20b55e8e2
8 changed files with 499 additions and 15 deletions
+13
View File
@@ -100,6 +100,16 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
assertNear(Number(documentObject.querySelector("#source-x").value), 0.3, 0.002, "canvas x");
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y");
dispatchChecked(windowObject, documentObject.querySelector("#source-motion"), true);
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "default trajectory points");
assertEqual(documentObject.querySelector(".trajectory-row input").value, "0", "trajectory starts at zero");
documentObject.querySelector("#add-keyframe-button").click();
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 3, "keyframe added");
documentObject.querySelectorAll(".trajectory-row")[1].querySelector(".trajectory-row__remove").click();
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "keyframe removed");
dispatchValue(windowObject, documentObject.querySelector("#preview-time"), "0.1");
assertEqual(documentObject.querySelector("#preview-time-value").textContent, "0.10 s", "preview time label");
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
@@ -122,6 +132,9 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
const firstScene = JSON.parse(firstJsonText);
assertEqual(firstScene.sources.length, 2, "exported source count");
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported");
assertEqual(firstScene.sources[1].trajectory.length, 2, "trajectory exported");
assertEqual(firstScene.sources[1].trajectory[0].position.join(","), firstScene.sources[1].position.join(","), "fallback position synced");
assertEqual(firstScene.sources[1].trajectory[1].time, firstScene.duration, "trajectory spans scene");
assertEqual(firstScene.ambient[0].signal.loop, true, "ambient loop exported");
assertEqual(firstScene.image.widthPixels, 640, "image width exported");
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
+31
View File
@@ -1,11 +1,17 @@
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
import {
assetDescriptorForFile,
disableSourceTrajectory,
enableSourceTrajectory,
createSceneId,
createSource,
insertTrajectoryKeyframe,
matchAssetDescriptor,
prepareSceneForEditing,
removeTrajectoryKeyframe,
referencedAssetIds,
scaleSceneTrajectories,
updateTrajectoryKeyframeTime,
} from "/apps/dataset-builder/model.js";
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
import {
@@ -43,6 +49,31 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
});
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
const source = { position: [0.5, 0.1, 2] };
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
assertEqual(trajectory.length, 2, "default keyframe count");
assertEqual(trajectory[0].time, 0, "path starts at scene start");
assertEqual(trajectory[1].time, 2, "path spans scene duration");
assert(trajectory[1].position[0] < trajectory[0].position[0], "edge path turns inward");
const inserted = insertTrajectoryKeyframe(source);
assertEqual(inserted, 1, "midpoint insertion index");
assertNear(source.trajectory[1].time, 1, 1e-12, "midpoint time");
assertNear(source.trajectory[1].position[0], 0.35, 1e-12, "midpoint x");
assertNear(updateTrajectoryKeyframeTime(source, 1, 3, 2), 1.999999998, 1e-12, "ordered time clamp");
source.trajectory[0].position = [-0.25, 0.2, 1.5];
scaleSceneTrajectories({ sources: [source] }, 2, 4);
assertNear(source.trajectory[1].time, 3.999999996, 1e-9, "duration scales keyframes");
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "first point fallback sync");
assert(removeTrajectoryKeyframe(source, 1), "remove optional midpoint");
assertEqual(removeTrajectoryKeyframe(source, 0), false, "two-point minimum");
disableSourceTrajectory(source);
assertEqual(source.trajectory, undefined, "motion disabled");
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "fallback remains after disable");
});
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
const assets = [
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },