@kokoa/clotho-editor 0.2.0 → 0.4.0

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.
@@ -1,5 +1,5 @@
1
- import { updateCanvas, getDef, subscribe, updateMeta, mountEditorPlugins, setSelection, getSelection, setDef, undo, redo, downloadAnimationJson, importAnimation, markClean, updateSettings, isDirty, isDraft, canUndo, canRedo, uniqueChapterId, getCurrentTime, addChapter, uniqueElementId, registerExternalAsset, addElement, registerDataUriAsset, listAnimations, setDraft, saveAnimation, deleteAnimation, duplicateAnimation, createAnimation, getSelectedElementIds, deleteElement, deleteChapter, deleteEffect, updateChapter, isGroup, ungroupElement, groupElements, moveElementToEnd, moveElementToFront, reorderElement, loadAnimation, getCurrentSnapshot, setElementValueAtTime, findContainingGroup, isElementSelected, updateElementBase, createLayout, detachFromLayout, addCheckpoint, uniqueCheckpointId, deleteCheckpoint, uniqueEffectId, addEffect, removeAppearance, removeTrack, removeTrackKeyframe, setTrackKeyframe, setCurrentTime, addAppearance, jumpBack, jumpForward, getHistory, promoteDraftToSaved, toggleSelectionFor, moveGroupBy, placeholderImageUrl, groupBbox, layoutIdsFor, findLayoutCollisions, updateLocales, updateData, updateResponsive, updateDuration, updateCheckpoint, updateAppearance, updateEffect, childIdsOf } from './chunk-FAI2G3XK.js';
2
- import { activeAppearance, animationDocumentSchema, bindablePropertiesFor, resolveAsset, compileDataBindings } from '@kokoa/clotho';
1
+ import { updateCanvas, getDef, subscribe, updateMeta, mountEditorPlugins, setSelection, getSelection, setDef, undo, redo, downloadAnimationJson, importAnimation, markClean, updateSettings, isDirty, isDraft, canUndo, canRedo, uniqueChapterId, getCurrentTime, addChapter, uniqueElementId, registerExternalAsset, addElement, registerDataUriAsset, listAnimations, setDraft, saveAnimation, deleteAnimation, duplicateAnimation, createAnimation, getSelectedElementIds, deleteElement, deleteChapter, deleteEffect, updateChapter, isGroup, ungroupElement, groupElements, moveElementToEnd, moveElementToFront, reorderElement, loadAnimation, getCurrentSnapshot, setElementValueAtTime, findContainingGroup, isElementSelected, updateElementBase, createLayout, detachFromLayout, addCheckpoint, uniqueCheckpointId, deleteCheckpoint, addCameraFocus, updateCameraFocus, deleteCameraFocus, setCameraKeyframe, removeCameraKeyframe, removeCameraTrack, setCurrentTime, clearCamera, uniqueEffectId, addEffect, removeAppearance, removeTrack, removeTrackKeyframe, setTrackKeyframe, addAppearance, beginTransient, endTransient, getCamera, jumpBack, jumpForward, getHistory, promoteDraftToSaved, toggleSelectionFor, cameraControlAt, moveGroupBy, placeholderImageUrl, groupBbox, hasCamera, layoutIdsFor, findLayoutCollisions, updateEffect, updateLocales, updateData, updateResponsive, updateDuration, updateCheckpoint, setCameraStrokeScaling, updateAppearance, moveCameraKeyframe, childIdsOf, replaceEffect } from './chunk-WOP52KFW.js';
2
+ import { activeAppearance, animationDocumentSchema, bindablePropertiesFor, computeCamera, resolveAsset, compileDataBindings } from '@kokoa/clotho';
3
3
 
4
4
  // src/legacy/grid.ts
5
5
  var STORAGE_KEY = "studio.grid";
@@ -46,6 +46,35 @@ function snapPoint(pt) {
46
46
  return { x: snap(pt.x), y: snap(pt.y) };
47
47
  }
48
48
 
49
+ // src/legacy/camera-interactions.ts
50
+ function cameraDragMode(part, control) {
51
+ if (control.kind === "none" || part === "border") return null;
52
+ if (part === "resize") return control.kind === "focus" ? "padding" : "zoom";
53
+ return control.kind === "tracks" ? "pan" : null;
54
+ }
55
+ function centreDistance(point, centreX, centreY) {
56
+ return Math.max(1, Math.hypot(point.x - centreX, point.y - centreY));
57
+ }
58
+ function cameraDragResult(drag, point) {
59
+ if (drag.mode === "pan") {
60
+ return {
61
+ centerX: Math.round(drag.startCenterX - (point.x - drag.startX)),
62
+ centerY: Math.round(drag.startCenterY - (point.y - drag.startY))
63
+ };
64
+ }
65
+ const ratio = centreDistance(point, drag.startCenterX, drag.startCenterY) / drag.startDistance;
66
+ if (drag.mode === "zoom") {
67
+ const zoom = Math.max(0.05, Math.min(20, drag.startZoom / ratio));
68
+ return { zoom: Number(zoom.toFixed(3)) };
69
+ }
70
+ return {
71
+ padding: Math.max(
72
+ 0,
73
+ Math.round(drag.startPadding * ratio + (ratio - 1) * 40)
74
+ )
75
+ };
76
+ }
77
+
49
78
  // src/legacy/canvas-utils.ts
50
79
  var SVG_NS = "http://www.w3.org/2000/svg";
51
80
  function escapeXml(s) {
@@ -1154,11 +1183,29 @@ var endpointDragState = null;
1154
1183
  var vertexDragState = null;
1155
1184
  var resizeState = null;
1156
1185
  var marqueeState = null;
1186
+ var cameraDragState = null;
1157
1187
  var marqueeJustFinished = false;
1158
1188
  var toolJustFinished = false;
1159
1189
  var canvasEl = null;
1160
1190
  var hoveredElementId = null;
1161
1191
  var canvasZoom = 1;
1192
+ var showCameraFrame = true;
1193
+ var CAMERA_FRAME_KEY = "studio.canvas.cameraFrame";
1194
+ try {
1195
+ showCameraFrame = localStorage.getItem(CAMERA_FRAME_KEY) !== "off";
1196
+ } catch {
1197
+ }
1198
+ function isCameraFrameVisible() {
1199
+ return showCameraFrame;
1200
+ }
1201
+ function setCameraFrameVisible(value) {
1202
+ showCameraFrame = value;
1203
+ try {
1204
+ localStorage.setItem(CAMERA_FRAME_KEY, value ? "on" : "off");
1205
+ } catch {
1206
+ }
1207
+ requestCanvasRender();
1208
+ }
1162
1209
  var toolDrawState = null;
1163
1210
  var elementDrawState = null;
1164
1211
  var canvasRenderFrame = null;
@@ -1291,7 +1338,7 @@ function onCanvasClick(e) {
1291
1338
  return;
1292
1339
  }
1293
1340
  if (target?.closest(
1294
- "[data-rotate-handle], [data-anchor-handle], [data-resize-handle]"
1341
+ "[data-rotate-handle], [data-anchor-handle], [data-resize-handle], [data-camera-frame]"
1295
1342
  ))
1296
1343
  return;
1297
1344
  const id = findElementId(e.target);
@@ -1304,6 +1351,8 @@ function onCanvasClick(e) {
1304
1351
  function onMouseDown(e) {
1305
1352
  if (e.button !== 0) return;
1306
1353
  const target = e.target;
1354
+ const cameraPart = target?.closest("[data-camera-frame]");
1355
+ if (cameraPart && beginCameraDrag(e, cameraPart)) return;
1307
1356
  const def = getDef();
1308
1357
  if (!def) return;
1309
1358
  const activeTool2 = getActiveTool();
@@ -1568,6 +1617,11 @@ function collectDragExtras(anchorId, def, snap2) {
1568
1617
  return extras;
1569
1618
  }
1570
1619
  function onMouseMove(e) {
1620
+ if (cameraDragState) {
1621
+ e.preventDefault();
1622
+ moveCameraDrag(e);
1623
+ return;
1624
+ }
1571
1625
  if (pathDraftState && getActiveTool() === "path") {
1572
1626
  const point = svgPoint(e.clientX, e.clientY);
1573
1627
  if (point) {
@@ -1768,6 +1822,11 @@ function finishPathDraft() {
1768
1822
  render2();
1769
1823
  }
1770
1824
  function onMouseUp(e) {
1825
+ if (cameraDragState) {
1826
+ cameraDragState = null;
1827
+ endTransient();
1828
+ return;
1829
+ }
1771
1830
  if (elementDrawState) {
1772
1831
  const draw = elementDrawState;
1773
1832
  const point = svgPoint(e.clientX, e.clientY);
@@ -2176,6 +2235,10 @@ function render2() {
2176
2235
  canvasEl.appendChild(g);
2177
2236
  }
2178
2237
  }
2238
+ const cameraFrame = renderCameraFrame(def);
2239
+ if (cameraFrame) canvasEl.appendChild(cameraFrame);
2240
+ const spotlightPreview = renderSpotlightPreview(def, snap2, elementsById);
2241
+ if (spotlightPreview) canvasEl.appendChild(spotlightPreview);
2179
2242
  const selection = getSelection();
2180
2243
  if (selection.kind === "element") {
2181
2244
  const selEl = elementsById.get(selection.elementId);
@@ -2386,6 +2449,199 @@ function renderGroupOutline(groupId) {
2386
2449
  `;
2387
2450
  return g;
2388
2451
  }
2452
+ function renderCameraFrame(def) {
2453
+ if (!showCameraFrame) return null;
2454
+ const view = computeCamera(def, getCurrentTime());
2455
+ if (!view) return null;
2456
+ const { width, height } = def.canvas;
2457
+ const selected = getSelection().kind === "camera";
2458
+ const control = cameraControlAt(getCurrentTime());
2459
+ const g = document.createElementNS(SVG_NS, "g");
2460
+ g.setAttribute(
2461
+ "class",
2462
+ `studio-camera-frame${selected ? " is-selected" : ""}`
2463
+ );
2464
+ g.setAttribute("pointer-events", "none");
2465
+ const scrim = document.createElementNS(SVG_NS, "path");
2466
+ scrim.setAttribute(
2467
+ "d",
2468
+ `M0 0H${width}V${height}H0Z M${view.x} ${view.y}H${view.x + view.width}V${view.y + view.height}H${view.x}Z`
2469
+ );
2470
+ scrim.setAttribute("fill-rule", "evenodd");
2471
+ scrim.setAttribute("class", "studio-camera-frame-scrim");
2472
+ g.appendChild(scrim);
2473
+ const rect = document.createElementNS(SVG_NS, "rect");
2474
+ rect.setAttribute("x", String(view.x));
2475
+ rect.setAttribute("y", String(view.y));
2476
+ rect.setAttribute("width", String(view.width));
2477
+ rect.setAttribute("height", String(view.height));
2478
+ rect.setAttribute("class", "studio-camera-frame-rect");
2479
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
2480
+ rect.setAttribute("data-camera-frame", "border");
2481
+ rect.setAttribute("pointer-events", "stroke");
2482
+ g.appendChild(rect);
2483
+ if (selected && control.kind !== "none") {
2484
+ g.appendChild(cameraHandles(view, control));
2485
+ }
2486
+ const label = document.createElementNS(SVG_NS, "text");
2487
+ label.setAttribute("x", String(view.x + 6));
2488
+ label.setAttribute("y", String(view.y + 16));
2489
+ label.setAttribute("class", "studio-camera-frame-label");
2490
+ label.textContent = selected ? `\u{1F3A5} ${view.zoom.toFixed(2)}\xD7 \xB7 ${Math.round(view.centerX)}, ${Math.round(view.centerY)} \xB7 ${control.kind === "focus" ? `focus #${control.index + 1} \u2014 \uBAA8\uC11C\uB9AC\uB97C \uB04C\uBA74 padding` : "\uB04C\uC5B4\uC11C \uC774\uB3D9 \xB7 \uBAA8\uC11C\uB9AC\uB85C zoom"}` : `\u{1F3A5} ${view.zoom.toFixed(2)}\xD7 \xB7 ${Math.round(view.centerX)}, ${Math.round(view.centerY)}`;
2491
+ g.appendChild(label);
2492
+ if (view.issues.length > 0) {
2493
+ const warning = document.createElementNS(SVG_NS, "text");
2494
+ warning.setAttribute("x", String(view.x + 6));
2495
+ warning.setAttribute("y", String(view.y + 32));
2496
+ warning.setAttribute("class", "studio-camera-frame-warning");
2497
+ warning.textContent = `\u26A0 ${view.issues[0].message}`;
2498
+ g.appendChild(warning);
2499
+ }
2500
+ return g;
2501
+ }
2502
+ function beginCameraDrag(e, part) {
2503
+ const def = getDef();
2504
+ if (!def) return false;
2505
+ const time = getCurrentTime();
2506
+ const view = computeCamera(def, time);
2507
+ if (!view) return false;
2508
+ const wasSelected = getSelection().kind === "camera";
2509
+ if (!wasSelected) setSelection({ kind: "camera" });
2510
+ e.preventDefault();
2511
+ e.stopPropagation();
2512
+ const kind = part.dataset.cameraFrame;
2513
+ if (kind === "border" || !wasSelected) return true;
2514
+ const control = cameraControlAt(time);
2515
+ const mode = cameraDragMode(kind, control);
2516
+ if (!mode) return true;
2517
+ const point = svgPoint(e.clientX, e.clientY);
2518
+ if (!point) return true;
2519
+ const focusIndex = control.kind === "focus" ? control.index : -1;
2520
+ const padding = focusIndex >= 0 ? getCamera().focus[focusIndex]?.padding ?? 24 : 0;
2521
+ cameraDragState = {
2522
+ mode,
2523
+ focusIndex,
2524
+ time,
2525
+ startX: point.x,
2526
+ startY: point.y,
2527
+ startCenterX: view.centerX,
2528
+ startCenterY: view.centerY,
2529
+ startZoom: view.zoom,
2530
+ startPadding: padding,
2531
+ startDistance: centreDistance(point, view.centerX, view.centerY)
2532
+ };
2533
+ beginTransient("\uCE74\uBA54\uB77C \uC870\uC815", "camera");
2534
+ return true;
2535
+ }
2536
+ function moveCameraDrag(e) {
2537
+ const drag = cameraDragState;
2538
+ if (!drag) return;
2539
+ const point = svgPoint(e.clientX, e.clientY);
2540
+ if (!point) return;
2541
+ const next = cameraDragResult(drag, point);
2542
+ if (next.centerX !== void 0) {
2543
+ setCameraKeyframe("x", drag.time, next.centerX);
2544
+ }
2545
+ if (next.centerY !== void 0) {
2546
+ setCameraKeyframe("y", drag.time, next.centerY);
2547
+ }
2548
+ if (next.zoom !== void 0) setCameraKeyframe("zoom", drag.time, next.zoom);
2549
+ if (next.padding !== void 0) {
2550
+ updateCameraFocus(drag.focusIndex, { padding: next.padding });
2551
+ }
2552
+ }
2553
+ function renderSpotlightPreview(def, snap2, elementsById) {
2554
+ const selection = getSelection();
2555
+ if (selection.kind !== "effect") return null;
2556
+ const effect = def.effects.find(
2557
+ (candidate) => candidate.id === selection.effectId
2558
+ );
2559
+ if (!effect || effect.type !== "spotlight") return null;
2560
+ const boxes = effect.elementIds.map((id) => {
2561
+ const el = elementsById.get(id);
2562
+ const state = el ? snap2.get(id) : void 0;
2563
+ return el && state ? elementBBox(el, state) : null;
2564
+ }).filter(
2565
+ (box) => box !== null
2566
+ );
2567
+ if (boxes.length === 0) return null;
2568
+ const { width, height } = def.canvas;
2569
+ const pad = effect.padding;
2570
+ const g = document.createElementNS(SVG_NS, "g");
2571
+ g.setAttribute("class", "studio-spotlight-preview");
2572
+ g.setAttribute("pointer-events", "none");
2573
+ const holes = [];
2574
+ if (effect.shape === "elements") {
2575
+ for (const box of boxes) {
2576
+ holes.push(
2577
+ `M${box.x - pad} ${box.y - pad}H${box.x + box.w + pad}V${box.y + box.h + pad}H${box.x - pad}Z`
2578
+ );
2579
+ }
2580
+ } else {
2581
+ const x = Math.min(...boxes.map((box) => box.x)) - pad;
2582
+ const y = Math.min(...boxes.map((box) => box.y)) - pad;
2583
+ const right = Math.max(...boxes.map((box) => box.x + box.w)) + pad;
2584
+ const bottom = Math.max(...boxes.map((box) => box.y + box.h)) + pad;
2585
+ if (effect.shape === "circle") {
2586
+ const cx = (x + right) / 2;
2587
+ const cy = (y + bottom) / 2;
2588
+ const r = Math.hypot(right - x, bottom - y) / 2;
2589
+ holes.push(
2590
+ `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0Z`
2591
+ );
2592
+ } else {
2593
+ holes.push(`M${x} ${y}H${right}V${bottom}H${x}Z`);
2594
+ }
2595
+ }
2596
+ const scrim = document.createElementNS(SVG_NS, "path");
2597
+ scrim.setAttribute("d", `M0 0H${width}V${height}H0Z ${holes.join(" ")}`);
2598
+ scrim.setAttribute("fill-rule", "evenodd");
2599
+ scrim.setAttribute("fill", effect.dimColor ?? "#0b1120");
2600
+ scrim.setAttribute("opacity", String(Math.max(effect.dim, 0.15)));
2601
+ g.appendChild(scrim);
2602
+ const label = document.createElementNS(SVG_NS, "text");
2603
+ label.setAttribute("x", "8");
2604
+ label.setAttribute("y", "18");
2605
+ label.setAttribute("class", "studio-spotlight-preview-label");
2606
+ const approximated = effect.shape === "elements" ? " \xB7 \uC2E4\uB8E8\uC5E3\uC740 \uC0C1\uC790\uB85C \uADFC\uC0AC" : "";
2607
+ label.textContent = `\u{1F526} ${effect.id} \xB7 ${effect.time}\u2013${effect.time + effect.duration}ms${approximated}`;
2608
+ g.appendChild(label);
2609
+ return g;
2610
+ }
2611
+ function cameraHandles(view, control) {
2612
+ const g = document.createElementNS(SVG_NS, "g");
2613
+ if (control.kind === "tracks") {
2614
+ const body = document.createElementNS(SVG_NS, "rect");
2615
+ body.setAttribute("x", String(view.x));
2616
+ body.setAttribute("y", String(view.y));
2617
+ body.setAttribute("width", String(view.width));
2618
+ body.setAttribute("height", String(view.height));
2619
+ body.setAttribute("class", "studio-camera-frame-body");
2620
+ body.setAttribute("data-camera-frame", "pan");
2621
+ body.setAttribute("pointer-events", "fill");
2622
+ g.appendChild(body);
2623
+ }
2624
+ const corners = [
2625
+ ["nw", view.x, view.y],
2626
+ ["ne", view.x + view.width, view.y],
2627
+ ["sw", view.x, view.y + view.height],
2628
+ ["se", view.x + view.width, view.y + view.height]
2629
+ ];
2630
+ const size = 9 / canvasZoom;
2631
+ for (const [corner, cx, cy] of corners) {
2632
+ const handle = document.createElementNS(SVG_NS, "rect");
2633
+ handle.setAttribute("x", String(cx - size / 2));
2634
+ handle.setAttribute("y", String(cy - size / 2));
2635
+ handle.setAttribute("width", String(size));
2636
+ handle.setAttribute("height", String(size));
2637
+ handle.setAttribute("class", "studio-camera-frame-handle");
2638
+ handle.setAttribute("data-camera-frame", "resize");
2639
+ handle.setAttribute("data-camera-corner", corner);
2640
+ handle.setAttribute("pointer-events", "all");
2641
+ g.appendChild(handle);
2642
+ }
2643
+ return g;
2644
+ }
2389
2645
  function makeG(elementId, rotation, cx, cy) {
2390
2646
  const g = document.createElementNS(SVG_NS, "g");
2391
2647
  g.setAttribute("data-elem-id", elementId);
@@ -2762,8 +3018,23 @@ function initProperties(root) {
2762
3018
  root.addEventListener("compositionend", onCompositionEnd);
2763
3019
  root.addEventListener("toggle", onSectionToggle, true);
2764
3020
  root.addEventListener("wheel", onNumberWheel, { passive: false });
3021
+ root.addEventListener("pointerdown", onSliderGrab);
3022
+ root.addEventListener("pointerup", onSliderRelease);
3023
+ root.addEventListener("pointercancel", onSliderRelease);
2765
3024
  render3();
2766
3025
  }
3026
+ var slidingKey = null;
3027
+ function onSliderGrab(e) {
3028
+ const target = e.target;
3029
+ if (target.type !== "range" || !target.dataset.propKey) return;
3030
+ slidingKey = target.dataset.propKey;
3031
+ beginTransient(`\uC870\uC815: ${slidingKey}`, "camera");
3032
+ }
3033
+ function onSliderRelease() {
3034
+ if (slidingKey === null) return;
3035
+ slidingKey = null;
3036
+ endTransient();
3037
+ }
2767
3038
  function onCompositionStart() {
2768
3039
  isComposingFallback = true;
2769
3040
  }
@@ -2981,9 +3252,12 @@ function renderInner() {
2981
3252
  <option value="highlight">highlight</option>
2982
3253
  <option value="pulse">pulse</option>
2983
3254
  <option value="flow">flow</option>
3255
+ <option value="spotlight">spotlight</option>
2984
3256
  </select>
2985
3257
  <button type="button" class="studio-btn" data-add-effect>\uFF0B \uD6A8\uACFC</button>
2986
3258
  </div>
3259
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uCE74\uBA54\uB77C</span><span class="studio-props-header-type">${hasCamera() ? `focus ${getCamera().focus.length} \xB7 track ${getCamera().tracks.length}` : "\uC5C6\uC74C"}</span></div>
3260
+ <button type="button" class="studio-btn" data-open-camera>\u{1F3A5} \uCE74\uBA54\uB77C \uD3B8\uC9D1</button>
2987
3261
  `;
2988
3262
  return;
2989
3263
  }
@@ -3042,6 +3316,10 @@ function renderInner() {
3042
3316
  renderElementForm(def, el);
3043
3317
  return;
3044
3318
  }
3319
+ if (sel.kind === "camera") {
3320
+ renderCameraForm(def, sel.focusIndex);
3321
+ return;
3322
+ }
3045
3323
  if (sel.kind === "chapter") {
3046
3324
  const ch = def.chapters.find((c) => c.id === sel.chapterId);
3047
3325
  if (!ch) {
@@ -3066,12 +3344,40 @@ function renderInner() {
3066
3344
  setSelection({ kind: "none" });
3067
3345
  return;
3068
3346
  }
3347
+ const typeOpts = ["highlight", "pulse", "flow", "spotlight"].map(
3348
+ (t) => `<option value="${t}" ${t === eff.type ? "selected" : ""}>${t}</option>`
3349
+ ).join("");
3350
+ if (eff.type === "spotlight") {
3351
+ const shapeOpts = ["bbox", "circle", "elements"].map(
3352
+ (shape) => `<option value="${shape}" ${shape === eff.shape ? "selected" : ""}>${shape}${shape === "bbox" ? " (\uAE30\uBCF8)" : ""}</option>`
3353
+ ).join("");
3354
+ panelEl.innerHTML = `
3355
+ ${timeHint}
3356
+ <div class="studio-props-header"><span class="studio-props-header-title">${escapeHtml2(eff.id)}</span><span class="studio-props-header-type">spotlight</span></div>
3357
+ <label class="studio-field"><span>type</span><select data-prop-key="effect.type">${typeOpts}</select></label>
3358
+ <p class="studio-props-empty" style="margin:0 0 0.4rem">\uB300\uC0C1\uC740 \uADF8\uB300\uB85C \uB450\uACE0 \uB098\uBA38\uC9C0\uB97C \uC5B4\uB461\uAC8C \uD569\uB2C8\uB2E4. \uB300\uC0C1\uC758 \uC0C9\uC774 \uC815\uBCF4\uC778 \uBB38\uC11C\uC5D0\uC11C <code>highlight</code> \uB300\uC2E0 \uC501\uB2C8\uB2E4.</p>
3359
+ ${numberField("time (ms)", "effect.time", eff.time, 50)}
3360
+ ${numberField("duration (ms)", "effect.duration", eff.duration, 50)}
3361
+ ${numberField("fadeIn (ms) \xB7 \uC591 \uB05D\uC5D0 \uC801\uC6A9", "effect.fadeIn", eff.fadeIn, 50)}
3362
+ <div class="studio-camera-field-label">\uB300\uC0C1 \uC694\uC18C (${eff.elementIds.length})</div>
3363
+ ${elementCheckList(def, "data-spotlight-target", eff.id, eff.elementIds)}
3364
+ <label class="studio-field"><span>shape</span><select data-prop-key="effect.shape">${shapeOpts}</select></label>
3365
+ <p class="studio-camera-hint">\uB300\uC0C1\uC774 \uBB34\uB300 \uC5EC\uAE30\uC800\uAE30 \uD769\uC5B4\uC838 \uC788\uC73C\uBA74 <code>elements</code>\uB97C \uC4F0\uC138\uC694. <code>bbox</code>\uB294 \uC14B\uC744 \uB2F4\uB290\uB77C \uADF8 \uC0AC\uC774\uC758 \uAD00\uACC4\uC5C6\uB294 \uC694\uC18C\uAE4C\uC9C0 \uBC1D\uD799\uB2C8\uB2E4.</p>
3366
+ ${rangeField("padding", "effect.padding", eff.padding, 0, 80, 1)}
3367
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uAC00\uB824\uC9C0\uB294 \uCABD</span></div>
3368
+ ${rangeField("dim", "effect.dim", eff.dim, 0, 1, 0.01, "0\uC774\uBA74 \uC5B4\uB461\uAC8C \uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.")}
3369
+ ${colorField("dimColor", "effect.dimColor", eff.dimColor ?? "#0b1120")}
3370
+ <p class="studio-camera-hint">\uBE44\uC6CC \uB450\uBA74 \uD14C\uB9C8 \uD1A0\uD070\uC744 \uC501\uB2C8\uB2E4 \u2014 \uB77C\uC774\uD2B8\xB7\uB2E4\uD06C \uBAA8\uB450 near-black.</p>
3371
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uBE44\uCD94\uB294 \uCABD</span></div>
3372
+ ${rangeField("lit", "effect.lit", eff.lit, 0, 1, 0.01, "0\uC774\uBA74 \uB300\uC0C1\uC740 \uC790\uAE30 \uC0C9 \uADF8\uB300\uB85C\uC785\uB2C8\uB2E4. \uC870\uBA85\uC758 \uC824\uCC98\uB7FC \uC0C9\uC744 \uC5B9\uC73C\uB824\uBA74 \uC62C\uB9AC\uC138\uC694.")}
3373
+ ${colorField("litColor", "effect.litColor", eff.litColor)}
3374
+ <button type="button" class="studio-btn studio-btn-danger" data-delete-effect style="margin-top:0.6rem">\u{1F5D1} \uD6A8\uACFC \uC0AD\uC81C</button>
3375
+ `;
3376
+ return;
3377
+ }
3069
3378
  const elemOpts = def.elements.map(
3070
3379
  (e) => `<option value="${escapeHtml2(e.id)}" ${e.id === eff.elementId ? "selected" : ""}>${escapeHtml2(e.id)}</option>`
3071
3380
  ).join("");
3072
- const typeOpts = ["highlight", "pulse", "flow"].map(
3073
- (t) => `<option value="${t}" ${t === eff.type ? "selected" : ""}>${t}</option>`
3074
- ).join("");
3075
3381
  const specific = eff.type === "highlight" ? colorField("color", "effect.color", eff.color) : eff.type === "pulse" ? numberField("scale", "effect.scale", eff.scale, 0.05) : colorField("color", "effect.color", eff.color) + numberField("particles", "effect.particles", eff.particles, 1) + numberField("radius", "effect.radius", eff.radius, 0.5);
3076
3382
  panelEl.innerHTML = `
3077
3383
  ${timeHint}
@@ -3086,6 +3392,114 @@ function renderInner() {
3086
3392
  return;
3087
3393
  }
3088
3394
  }
3395
+ function rangeField(label, key, value, min, max, step, hint) {
3396
+ return `<div class="studio-camera-range">
3397
+ <div class="studio-camera-range-head"><span>${escapeHtml2(label)}</span><span class="studio-camera-range-value">${value}</span></div>
3398
+ <div class="studio-camera-range-row">
3399
+ <input type="range" data-prop-key="${escapeHtml2(key)}" min="${min}" max="${max}" step="${step}" value="${value}" />
3400
+ <input type="number" data-prop-key="${escapeHtml2(key)}" step="${step}" value="${value}" />
3401
+ </div>
3402
+ ${hint ? `<p class="studio-camera-hint">${escapeHtml2(hint)}</p>` : ""}
3403
+ </div>`;
3404
+ }
3405
+ function elementCheckList(def, attribute, key, selected) {
3406
+ const chosen = new Set(selected);
3407
+ const rows = def.elements.map((element) => {
3408
+ const name = friendlyElementLabel(element);
3409
+ const same = name === element.id;
3410
+ return `<label class="studio-camera-target ${chosen.has(element.id) ? "is-on" : ""}">
3411
+ <input type="checkbox" ${attribute}="${escapeHtml2(key)}" value="${escapeHtml2(element.id)}" ${chosen.has(element.id) ? "checked" : ""} />
3412
+ <span class="studio-camera-target-name">${escapeHtml2(name)}</span>
3413
+ ${same ? "" : `<span class="studio-camera-target-id">${escapeHtml2(element.id)}</span>`}
3414
+ <span class="studio-camera-target-type">${element.type}</span>
3415
+ </label>`;
3416
+ }).join("");
3417
+ const missing = selected.filter(
3418
+ (id) => !def.elements.some((element) => element.id === id)
3419
+ );
3420
+ return `<div class="studio-camera-targets">
3421
+ ${rows || '<p class="studio-props-empty">\uC694\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p>'}
3422
+ ${missing.length > 0 ? `<p class="studio-camera-issue">\u26A0 \uBB38\uC11C\uC5D0 \uC5C6\uB294 id: ${escapeHtml2(missing.join(", "))}</p>` : ""}
3423
+ </div>`;
3424
+ }
3425
+ function renderCameraForm(def, focusIndex) {
3426
+ if (!panelEl) return;
3427
+ const camera = getCamera();
3428
+ const time = getCurrentTime();
3429
+ const view = computeCamera(def, time);
3430
+ const control = cameraControlAt(time);
3431
+ const scalingOpts = ["scale", "fixed"].map(
3432
+ (value) => `<option value="${value}" ${value === camera.strokeScaling ? "selected" : ""}>${value}${value === "scale" ? " (\uAE30\uBCF8)" : ""}</option>`
3433
+ ).join("");
3434
+ const controlNote = control.kind === "focus" ? `focus #${control.index + 1}\uC774(\uAC00) \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD569\uB2C8\uB2E4 \xB7 \uCE94\uBC84\uC2A4 \uBAA8\uC11C\uB9AC\uB97C \uB04C\uBA74 padding` : control.kind === "tracks" ? "track\uC774 \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD569\uB2C8\uB2E4 \xB7 \uCE94\uBC84\uC2A4\uC5D0\uC11C \uB04C\uC5B4 \uC774\uB3D9, \uBAA8\uC11C\uB9AC\uB85C zoom" : "\uCE74\uBA54\uB77C \uC5C6\uC74C";
3435
+ const readout = view ? `<div class="studio-camera-readout">zoom ${view.zoom.toFixed(2)}\xD7 \xB7 center ${Math.round(view.centerX)}, ${Math.round(view.centerY)}
3436
+ <br/><span class="studio-camera-control">${escapeHtml2(controlNote)}</span>${view.issues.length > 0 ? `<br/><span class="studio-camera-issue">\u26A0 ${escapeHtml2(view.issues[0].message)}</span>` : ""}</div>` : '<div class="studio-camera-readout">\uC774 \uBB38\uC11C\uC5D0\uB294 \uCE74\uBA54\uB77C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uC544\uB798\uC5D0\uC11C focus\uB098 track\uC744 \uCD94\uAC00\uD558\uBA74 \uC0DD\uAE41\uB2C8\uB2E4.</div>';
3437
+ const focusRows = camera.focus.map((entry, index) => {
3438
+ const open = index === focusIndex;
3439
+ const targets = entry.elementIds.join(", ");
3440
+ const preset = (value, label) => `<button type="button" class="studio-chip ${entry.duration === value ? "is-on" : ""}" data-camera-duration="${index}:${value}">${label}</button>`;
3441
+ return `
3442
+ <details class="studio-camera-focus" ${open ? "open" : ""}>
3443
+ <summary>#${index + 1} \xB7 ${entry.time}ms \u2192 ${escapeHtml2(targets)}</summary>
3444
+ <div class="studio-camera-focus-actions">
3445
+ <button type="button" class="studio-btn" data-camera-seek="${entry.time}" title="\uC774 focus\uAC00 \uC2DC\uC791\uD558\uB294 \uC2DC\uAC01\uC73C\uB85C \uC774\uB3D9">\u23F1 ${entry.time}ms\uB85C \uC774\uB3D9</button>
3446
+ <button type="button" class="studio-btn" data-camera-focus-now="${index}" title="\uD604\uC7AC \uC7AC\uC0DD \uC704\uCE58\uB97C \uC774 focus\uC758 \uC2DC\uC791 \uC2DC\uAC01\uC73C\uB85C">\uD604\uC7AC \uC2DC\uAC01\uC73C\uB85C</button>
3447
+ </div>
3448
+ ${numberField("time (ms)", `camera.focus.${index}.time`, entry.time, 50)}
3449
+ <div class="studio-camera-range">
3450
+ <div class="studio-camera-range-head"><span>duration (ms)</span><span class="studio-camera-range-value">${entry.duration}</span></div>
3451
+ <div class="studio-camera-chips">${preset(0, "\uCEF7")}${preset(300, "300")}${preset(600, "600")}${preset(1e3, "1000")}${preset(1600, "1600")}</div>
3452
+ <div class="studio-camera-range-row">
3453
+ <input type="range" data-prop-key="camera.focus.${index}.duration" min="0" max="3000" step="50" value="${entry.duration}" />
3454
+ <input type="number" data-prop-key="camera.focus.${index}.duration" step="50" value="${entry.duration}" />
3455
+ </div>
3456
+ </div>
3457
+ <div class="studio-camera-field-label">\uB300\uC0C1 \uC694\uC18C (${entry.elementIds.length})</div>
3458
+ ${elementCheckList(def, "data-camera-target", String(index), entry.elementIds)}
3459
+ ${rangeField("padding", `camera.focus.${index}.padding`, entry.padding, 0, 200, 2, "\uB300\uC0C1 \uC8FC\uBCC0 \uC5EC\uBC31. \uD074\uC218\uB85D \uB113\uAC8C \uC7A1\uC2B5\uB2C8\uB2E4.")}
3460
+ ${rangeField("maxZoom", `camera.focus.${index}.maxZoom`, entry.maxZoom, 1, 8, 0.1, "\uD655\uB300 \uC0C1\uD55C. \uC791\uC740 \uC694\uC18C \uD558\uB098\uAC00 \uD654\uBA74\uC744 \uCC44\uC6B0\uB294 \uAC83\uC744 \uB9C9\uC2B5\uB2C8\uB2E4.")}
3461
+ <button type="button" class="studio-btn studio-btn-danger" data-delete-camera-focus="${index}">\u{1F5D1} focus \uC0AD\uC81C</button>
3462
+ </details>`;
3463
+ }).join("");
3464
+ const trackRows = ["zoom", "x", "y"].map((property) => {
3465
+ const track = camera.tracks.find((t) => t.property === property);
3466
+ const current = property === "zoom" ? view?.zoom ?? 1 : property === "x" ? view?.centerX ?? def.canvas.width / 2 : view?.centerY ?? def.canvas.height / 2;
3467
+ const keyframes = (track?.keyframes ?? []).map(
3468
+ (kf) => `<li><button type="button" class="studio-camera-kf" data-camera-seek="${kf.time}">${kf.time}ms</button><span>${kf.value}</span><button type="button" class="studio-camera-kf-del" data-camera-kf-del="${property}:${kf.time}" title="keyframe \uC0AD\uC81C">\u2715</button></li>`
3469
+ ).join("");
3470
+ const bounds = property === "zoom" ? { min: 0.1, max: 8, step: 0.05 } : property === "x" ? { min: 0, max: def.canvas.width, step: 1 } : { min: 0, max: def.canvas.height, step: 1 };
3471
+ return `
3472
+ <div class="studio-camera-track">
3473
+ <div class="studio-camera-track-head">
3474
+ <span>${property}</span>
3475
+ <span class="studio-camera-track-now">\uD604\uC7AC ${current.toFixed(property === "zoom" ? 2 : 0)}</span>
3476
+ ${track ? `<button type="button" class="studio-btn studio-btn-danger" data-del-camera-track="${property}" title="track \uC0AD\uC81C">\u{1F5D1}</button>` : ""}
3477
+ </div>
3478
+ ${control.kind === "focus" ? `<div class="studio-camera-range-row">
3479
+ <input type="number" data-prop-key="camera.kf.${property}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3480
+ </div>
3481
+ <p class="studio-camera-hint">focus #${control.index + 1}\uC774(\uAC00) \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD558\uBBC0\uB85C \uC9C0\uAE08\uC740 \uD654\uBA74\uC774 \uBC14\uB00C\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uAC12\uC740 t = ${time}ms \uC5D0 \uAE30\uB85D\uB429\uB2C8\uB2E4.</p>` : `<div class="studio-camera-range-row">
3482
+ <input type="range" data-prop-key="camera.kf.${property}" min="${bounds.min}" max="${bounds.max}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3483
+ <input type="number" data-prop-key="camera.kf.${property}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3484
+ </div>
3485
+ <p class="studio-camera-hint">\uC6C0\uC9C1\uC774\uBA74 t = ${time}ms \uC5D0 keyframe\uC744 \uC501\uB2C8\uB2E4.</p>`}
3486
+ ${keyframes ? `<ul class="studio-camera-kf-list">${keyframes}</ul>` : '<p class="studio-props-empty">keyframe \uC5C6\uC74C</p>'}
3487
+ </div>`;
3488
+ }).join("");
3489
+ panelEl.innerHTML = `
3490
+ <span class="studio-step-hint">\u{1F4CD} t = ${time} ms / ${def.duration} ms</span>
3491
+ <div class="studio-props-header"><span class="studio-props-header-title">\uCE74\uBA54\uB77C</span><span class="studio-props-header-type">camera</span></div>
3492
+ ${readout}
3493
+ <label class="studio-field"><span>strokeScaling</span><select data-prop-key="camera.strokeScaling">${scalingOpts}</select></label>
3494
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">focus (${camera.focus.length})</span></div>
3495
+ <p class="studio-props-empty" style="margin:0 0 0.4rem">\uC694\uC18C\uB97C \uC9C0\uC815\uD558\uBA74 \uADF8 \uC694\uC18C\uAC00 \uD654\uBA74\uC5D0 \uB2F4\uAE30\uB3C4\uB85D \uCE74\uBA54\uB77C\uB97C \uACC4\uC0B0\uD569\uB2C8\uB2E4. \uB300\uC0C1\uC774 \uC6C0\uC9C1\uC774\uBA74 \uB530\uB77C\uAC11\uB2C8\uB2E4.</p>
3496
+ ${focusRows}
3497
+ <button type="button" class="studio-btn" data-add-camera-focus style="margin-top:0.4rem">\uFF0B \uD604\uC7AC \uC2DC\uAC01\uC5D0 focus</button>
3498
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">tracks</span></div>
3499
+ ${trackRows}
3500
+ ${hasCamera() ? '<button type="button" class="studio-btn studio-btn-danger" data-clear-camera style="margin-top:0.8rem">\u{1F5D1} \uCE74\uBA54\uB77C \uC81C\uAC70</button>' : ""}
3501
+ `;
3502
+ }
3089
3503
  function renderElementForm(def, el) {
3090
3504
  if (!panelEl) return;
3091
3505
  const timeHint = `<span class="studio-step-hint">\u{1F4CD} t = ${getCurrentTime()} ms</span>`;
@@ -3367,6 +3781,16 @@ function renderTracks(el) {
3367
3781
  }
3368
3782
  function onInput(e) {
3369
3783
  const target = e.target;
3784
+ const focusTarget = target.dataset.cameraTarget;
3785
+ if (focusTarget !== void 0 && target instanceof HTMLInputElement) {
3786
+ toggleFocusTarget(Number(focusTarget), target.value, target.checked);
3787
+ return;
3788
+ }
3789
+ const spotTarget = target.dataset.spotlightTarget;
3790
+ if (spotTarget !== void 0 && target instanceof HTMLInputElement) {
3791
+ toggleSpotlightTarget(spotTarget, target.value, target.checked);
3792
+ return;
3793
+ }
3370
3794
  const key = target.dataset.propKey;
3371
3795
  if (!key) return;
3372
3796
  if (target.type === "text" && e.isComposing) return;
@@ -3385,6 +3809,16 @@ function onInput(e) {
3385
3809
  value = target.checked;
3386
3810
  apply(key, value);
3387
3811
  }
3812
+ function toggleFocusTarget(index, id, on) {
3813
+ const entry = getCamera().focus[index];
3814
+ if (!entry) return;
3815
+ const next = on ? [...entry.elementIds, id] : entry.elementIds.filter((value) => value !== id);
3816
+ if (next.length === 0) {
3817
+ render3();
3818
+ return;
3819
+ }
3820
+ updateCameraFocus(index, { elementIds: next });
3821
+ }
3388
3822
  function onChange(e) {
3389
3823
  const target = e.target;
3390
3824
  if (target instanceof HTMLInputElement && target.type === "color") {
@@ -3485,6 +3919,33 @@ function apply(key, value) {
3485
3919
  predicate: { type: "equals", value: answer }
3486
3920
  });
3487
3921
  }
3922
+ } else if (key === "camera.strokeScaling")
3923
+ setCameraStrokeScaling(String(value));
3924
+ else if (key.startsWith("camera.kf.")) {
3925
+ const property = key.slice("camera.kf.".length);
3926
+ const raw = Number(value);
3927
+ if (!Number.isFinite(raw)) return;
3928
+ setCameraKeyframe(
3929
+ property,
3930
+ getCurrentTime(),
3931
+ property === "zoom" ? Math.max(0.05, raw) : Math.round(raw)
3932
+ );
3933
+ } else if (key.startsWith("camera.focus.")) {
3934
+ const [, , indexText, field] = key.split(".");
3935
+ const index = Number(indexText);
3936
+ if (!Number.isInteger(index)) return;
3937
+ if (field === "elementIds") {
3938
+ const ids = String(value).split(",").map((id) => id.trim()).filter(Boolean);
3939
+ if (ids.length > 0) updateCameraFocus(index, { elementIds: ids });
3940
+ return;
3941
+ }
3942
+ if (field === "time") updateCameraFocus(index, { time: Number(value) });
3943
+ else if (field === "duration")
3944
+ updateCameraFocus(index, { duration: Math.max(0, Number(value)) });
3945
+ else if (field === "padding")
3946
+ updateCameraFocus(index, { padding: Math.max(0, Number(value)) });
3947
+ else if (field === "maxZoom")
3948
+ updateCameraFocus(index, { maxZoom: Math.max(0.01, Number(value)) });
3488
3949
  } else if (key.startsWith("el.") && sel.kind === "element") {
3489
3950
  const prop = key.slice(3);
3490
3951
  const time = getCurrentTime();
@@ -3579,10 +4040,12 @@ function apply(key, value) {
3579
4040
  if (targets) references[token] = targets;
3580
4041
  else delete references[token];
3581
4042
  updateChapter(sel.chapterId, { references });
4043
+ } else if (key === "effect.type" && sel.kind === "effect") {
4044
+ changeEffectType(def, sel.effectId, String(value));
3582
4045
  } else if (key.startsWith("effect.") && sel.kind === "effect") {
3583
4046
  const prop = key.slice(7);
3584
4047
  const patch = {};
3585
- if (prop === "time" || prop === "duration" || prop === "scale" || prop === "particles" || prop === "radius") {
4048
+ if (prop === "time" || prop === "duration" || prop === "scale" || prop === "particles" || prop === "radius" || prop === "dim" || prop === "lit" || prop === "padding" || prop === "fadeIn") {
3586
4049
  patch[prop] = Number(value);
3587
4050
  } else {
3588
4051
  patch[prop] = value;
@@ -3590,6 +4053,52 @@ function apply(key, value) {
3590
4053
  updateEffect(sel.effectId, patch);
3591
4054
  }
3592
4055
  }
4056
+ function changeEffectType(def, effectId, type) {
4057
+ const current = def.effects.find((effect) => effect.id === effectId);
4058
+ if (!current || current.type === type) return;
4059
+ const targets = current.type === "spotlight" ? current.elementIds : [current.elementId];
4060
+ const base = { id: current.id, time: current.time };
4061
+ let next;
4062
+ if (type === "spotlight") {
4063
+ next = {
4064
+ ...base,
4065
+ type: "spotlight",
4066
+ elementIds: targets,
4067
+ duration: Math.max(current.duration, 800),
4068
+ dim: 0.7,
4069
+ lit: 0,
4070
+ litColor: "#fde68a",
4071
+ shape: "bbox",
4072
+ padding: 12,
4073
+ fadeIn: 200
4074
+ };
4075
+ } else {
4076
+ const single = {
4077
+ ...base,
4078
+ elementId: targets[0] ?? "",
4079
+ duration: current.duration
4080
+ };
4081
+ next = type === "pulse" ? { ...single, type: "pulse", scale: 1.12 } : type === "flow" ? {
4082
+ ...single,
4083
+ type: "flow",
4084
+ color: "#facc15",
4085
+ particles: 3,
4086
+ radius: 4
4087
+ } : { ...single, type: "highlight", color: "#facc15" };
4088
+ }
4089
+ replaceEffect(effectId, next);
4090
+ }
4091
+ function toggleSpotlightTarget(effectId, id, on) {
4092
+ const def = getDef();
4093
+ const effect = def?.effects.find((candidate) => candidate.id === effectId);
4094
+ if (!effect || effect.type !== "spotlight") return;
4095
+ const next = on ? [...effect.elementIds, id] : effect.elementIds.filter((target) => target !== id);
4096
+ if (next.length === 0) {
4097
+ render3();
4098
+ return;
4099
+ }
4100
+ updateEffect(effectId, { elementIds: next });
4101
+ }
3593
4102
  function parseBindingFallback(value) {
3594
4103
  if (value === "") return void 0;
3595
4104
  if (value === "true") return true;
@@ -3682,6 +4191,74 @@ function onClick2(e) {
3682
4191
  });
3683
4192
  return;
3684
4193
  }
4194
+ if (target.closest("[data-open-camera]")) {
4195
+ setSelection({ kind: "camera" });
4196
+ return;
4197
+ }
4198
+ if (target.closest("[data-add-camera-focus]")) {
4199
+ const seed = sel.kind === "element" ? [sel.elementId] : sel.kind === "elements" ? [...sel.elementIds] : def.elements[0] ? [def.elements[0].id] : [];
4200
+ if (seed.length === 0) return;
4201
+ addCameraFocus({
4202
+ time: getCurrentTime(),
4203
+ duration: 600,
4204
+ elementIds: seed,
4205
+ padding: 24,
4206
+ maxZoom: 4
4207
+ });
4208
+ return;
4209
+ }
4210
+ const durationChip = target.closest("[data-camera-duration]");
4211
+ if (durationChip) {
4212
+ const [index, duration] = (durationChip.dataset.cameraDuration ?? "").split(
4213
+ ":"
4214
+ );
4215
+ updateCameraFocus(Number(index), { duration: Number(duration) });
4216
+ return;
4217
+ }
4218
+ const focusNow = target.closest("[data-camera-focus-now]");
4219
+ if (focusNow) {
4220
+ updateCameraFocus(Number(focusNow.dataset.cameraFocusNow), {
4221
+ time: getCurrentTime()
4222
+ });
4223
+ return;
4224
+ }
4225
+ const deleteFocus = target.closest("[data-delete-camera-focus]");
4226
+ if (deleteFocus) {
4227
+ deleteCameraFocus(Number(deleteFocus.dataset.deleteCameraFocus));
4228
+ return;
4229
+ }
4230
+ const addCameraKf = target.closest("[data-add-camera-kf]");
4231
+ if (addCameraKf) {
4232
+ const property = addCameraKf.dataset.addCameraKf;
4233
+ const view = computeCamera(def, getCurrentTime());
4234
+ const value = property === "zoom" ? view?.zoom ?? 1 : property === "x" ? view?.centerX ?? def.canvas.width / 2 : view?.centerY ?? def.canvas.height / 2;
4235
+ setCameraKeyframe(
4236
+ property,
4237
+ getCurrentTime(),
4238
+ Number(value.toFixed(property === "zoom" ? 3 : 1))
4239
+ );
4240
+ return;
4241
+ }
4242
+ const delCameraKf = target.closest("[data-camera-kf-del]");
4243
+ if (delCameraKf) {
4244
+ const [property, at] = (delCameraKf.dataset.cameraKfDel ?? "").split(":");
4245
+ removeCameraKeyframe(property, Number(at));
4246
+ return;
4247
+ }
4248
+ const delCameraTrack = target.closest("[data-del-camera-track]");
4249
+ if (delCameraTrack) {
4250
+ removeCameraTrack(delCameraTrack.dataset.delCameraTrack);
4251
+ return;
4252
+ }
4253
+ const cameraSeek = target.closest("[data-camera-seek]");
4254
+ if (cameraSeek) {
4255
+ setCurrentTime(Number(cameraSeek.dataset.cameraSeek));
4256
+ return;
4257
+ }
4258
+ if (target.closest("[data-clear-camera]")) {
4259
+ clearCamera();
4260
+ return;
4261
+ }
3685
4262
  if (target.closest("[data-add-chapter]")) {
3686
4263
  const id = uniqueChapterId();
3687
4264
  addChapter({
@@ -3712,7 +4289,22 @@ function onClick2(e) {
3712
4289
  duration: 500
3713
4290
  };
3714
4291
  let eff;
3715
- if (type === "highlight")
4292
+ if (type === "spotlight") {
4293
+ const selected = sel.kind === "element" ? [sel.elementId] : sel.kind === "elements" ? [...sel.elementIds] : [firstEl.id];
4294
+ eff = {
4295
+ id,
4296
+ type: "spotlight",
4297
+ elementIds: selected,
4298
+ time: getCurrentTime(),
4299
+ duration: 1200,
4300
+ dim: 0.7,
4301
+ lit: 0,
4302
+ litColor: "#fde68a",
4303
+ shape: "bbox",
4304
+ padding: 12,
4305
+ fadeIn: 200
4306
+ };
4307
+ } else if (type === "highlight")
3716
4308
  eff = { ...base, type: "highlight", color: "#facc15" };
3717
4309
  else if (type === "pulse") eff = { ...base, type: "pulse", scale: 1.12 };
3718
4310
  else
@@ -3930,6 +4522,7 @@ function render4() {
3930
4522
  <div class="studio-tl-chapter-label">${escapeHtml3(c.label || c.id)}</div>
3931
4523
  </div>`;
3932
4524
  }).join("");
4525
+ const cameraRows = renderCameraRows(totalPx, sel);
3933
4526
  const playheadCol = `<div class="studio-tl-playhead-col" style="left:${GUTTER_PX}px;width:${totalPx}px"><div class="studio-tl-playhead" style="left:${timeToPx(currentTime)}px" title="t=${currentTime}ms"></div></div>`;
3934
4527
  tracksEl.innerHTML = `
3935
4528
  <div class="studio-tl-chart">
@@ -3947,12 +4540,48 @@ function render4() {
3947
4540
  </div>
3948
4541
  </div>
3949
4542
  </div>
4543
+ ${cameraRows}
3950
4544
  ${playheadCol}
3951
4545
  </div>
3952
4546
  <div class="studio-tl-total">\uC804\uCCB4 ${def.duration} ms \xB7 ${sortedChapters.length} chapters \xB7 ${def.elements.length} elements \xB7 \uD604\uC7AC ${currentTime} ms</div>
3953
4547
  `;
3954
4548
  renderElementTracks(def.elements, currentTime, totalPx, sel);
3955
4549
  }
4550
+ function renderCameraRows(totalPx, sel) {
4551
+ const camera = getCamera();
4552
+ const isCameraSel = sel.kind === "camera";
4553
+ const focusBars = camera.focus.map((entry, index) => {
4554
+ const left = timeToPx(entry.time);
4555
+ const width = Math.max(6, timeToPx(entry.duration));
4556
+ const selected = isCameraSel && sel.focusIndex === index;
4557
+ const targets = entry.elementIds.join(", ");
4558
+ return `<div class="studio-tl-camera-focus ${selected ? "is-selected" : ""}" style="left:${left}px;width:${width}px" data-camera-focus="${index}" title="focus \u2192 ${escapeHtml3(targets)} @ ${entry.time}ms (${entry.duration}ms, \uB4DC\uB798\uADF8\uB85C \uC774\uB3D9)">
4559
+ <span class="studio-tl-camera-focus-label">${escapeHtml3(targets)}</span>
4560
+ </div>`;
4561
+ }).join("");
4562
+ const focusRow = `
4563
+ <div class="studio-tl-row studio-tl-camera-row ${isCameraSel && sel.focusIndex === void 0 ? "is-selected" : ""}">
4564
+ <div class="studio-tl-gutter studio-tl-camera-gutter" data-camera-select title="\uCE74\uBA54\uB77C \uC18D\uC131 \uC5F4\uAE30">\u{1F3A5} Camera<button type="button" class="studio-tl-camera-add" data-add-camera-focus title="\uD604\uC7AC \uC2DC\uAC01\uC5D0 focus \uCD94\uAC00">\uFF0B</button></div>
4565
+ <div class="studio-tl-body" style="width:${totalPx}px">
4566
+ <div class="studio-tl-camera-track" data-tl-area="camera">
4567
+ ${focusBars || '<span class="studio-tl-camera-empty">focus \uC5C6\uC74C \xB7 \uFF0B \uB85C \uCD94\uAC00</span>'}
4568
+ </div>
4569
+ </div>
4570
+ </div>`;
4571
+ const trackRows = camera.tracks.map((track) => {
4572
+ const marks = track.keyframes.map(
4573
+ (kf) => `<div class="studio-tl-keyframe studio-tl-camera-keyframe" style="left:${timeToPx(kf.time)}px" data-camera-kf-prop="${track.property}" data-camera-kf-time="${kf.time}" title="${track.property} = ${kf.value} @ ${kf.time}ms (\uB4DC\uB798\uADF8\uB85C \uC774\uB3D9)">\u25C6</div>`
4574
+ ).join("");
4575
+ return `
4576
+ <div class="studio-tl-row studio-tl-camera-row">
4577
+ <div class="studio-tl-gutter studio-tl-camera-gutter is-sub" data-camera-select>\u21B3 ${track.property}</div>
4578
+ <div class="studio-tl-body" style="width:${totalPx}px">
4579
+ <div class="studio-tl-camera-track" data-tl-area="camera">${marks}</div>
4580
+ </div>
4581
+ </div>`;
4582
+ }).join("");
4583
+ return focusRow + trackRows;
4584
+ }
3956
4585
  function gutterLabel(el) {
3957
4586
  return `${friendlyElementLabel(el)} \xB7 ${el.type}`;
3958
4587
  }
@@ -4008,6 +4637,32 @@ function renderElementTracks(elements, currentTime, totalPx, sel) {
4008
4637
  }
4009
4638
  function onTracksClick(e) {
4010
4639
  const target = e.target;
4640
+ if (target.closest("[data-add-camera-focus]")) {
4641
+ const time = getCurrentTime();
4642
+ const selected = getSelection();
4643
+ const elementIds = selected.kind === "element" ? [selected.elementId] : selected.kind === "elements" ? [...selected.elementIds] : (getDef()?.elements[0]?.id ?? "").length > 0 ? [getDef().elements[0].id] : [];
4644
+ if (elementIds.length === 0) return;
4645
+ addCameraFocus({
4646
+ time,
4647
+ duration: 600,
4648
+ elementIds,
4649
+ padding: 24,
4650
+ maxZoom: 4
4651
+ });
4652
+ return;
4653
+ }
4654
+ const focusBar = target.closest("[data-camera-focus]");
4655
+ if (focusBar) {
4656
+ setSelection({
4657
+ kind: "camera",
4658
+ focusIndex: Number(focusBar.dataset.cameraFocus)
4659
+ });
4660
+ return;
4661
+ }
4662
+ if (target.closest("[data-camera-select]")) {
4663
+ setSelection({ kind: "camera" });
4664
+ return;
4665
+ }
4011
4666
  const chapterMarker = target.closest("[data-chapter-id]");
4012
4667
  if (chapterMarker) {
4013
4668
  setSelection({
@@ -4097,6 +4752,36 @@ function onMouseDown2(e) {
4097
4752
  }
4098
4753
  return;
4099
4754
  }
4755
+ const cameraKf = target.closest("[data-camera-kf-prop]");
4756
+ if (cameraKf) {
4757
+ e.preventDefault();
4758
+ e.stopPropagation();
4759
+ const startTime = Number(cameraKf.dataset.cameraKfTime);
4760
+ dragMode = {
4761
+ kind: "camera-keyframe",
4762
+ prop: cameraKf.dataset.cameraKfProp,
4763
+ startTime,
4764
+ startMouseX: e.clientX,
4765
+ currentTime: startTime
4766
+ };
4767
+ return;
4768
+ }
4769
+ const cameraFocus = target.closest("[data-camera-focus]");
4770
+ if (cameraFocus) {
4771
+ e.preventDefault();
4772
+ e.stopPropagation();
4773
+ const index = Number(cameraFocus.dataset.cameraFocus);
4774
+ const entry = getCamera().focus[index];
4775
+ if (entry) {
4776
+ dragMode = {
4777
+ kind: "camera-focus",
4778
+ index,
4779
+ startTime: entry.time,
4780
+ startMouseX: e.clientX
4781
+ };
4782
+ }
4783
+ return;
4784
+ }
4100
4785
  const chapterMarker = target.closest("[data-chapter-id]");
4101
4786
  if (chapterMarker) {
4102
4787
  e.preventDefault();
@@ -4157,6 +4842,29 @@ function onMouseMove2(e) {
4157
4842
  showDragTooltip(e.clientX, e.clientY, `\u25C6 ${dragMode.prop} @ ${newTime} ms`);
4158
4843
  return;
4159
4844
  }
4845
+ if (dragMode.kind === "camera-keyframe") {
4846
+ const dt = Math.round((e.clientX - dragMode.startMouseX) / pxPerMs);
4847
+ const newTime = Math.max(0, dragMode.startTime + dt);
4848
+ if (newTime !== dragMode.currentTime) {
4849
+ moveCameraKeyframe(dragMode.prop, dragMode.currentTime, newTime);
4850
+ dragMode.currentTime = newTime;
4851
+ }
4852
+ showDragTooltip(
4853
+ e.clientX,
4854
+ e.clientY,
4855
+ `\u{1F3A5} ${dragMode.prop} @ ${newTime} ms`
4856
+ );
4857
+ return;
4858
+ }
4859
+ if (dragMode.kind === "camera-focus") {
4860
+ const dt = Math.round((e.clientX - dragMode.startMouseX) / pxPerMs);
4861
+ const newTime = Math.max(0, dragMode.startTime + dt);
4862
+ updateCameraFocus(dragMode.index, { time: newTime });
4863
+ const moved = getCamera().focus.findIndex((f) => f.time === newTime);
4864
+ if (moved >= 0) dragMode.index = moved;
4865
+ showDragTooltip(e.clientX, e.clientY, `\u{1F3A5} focus @ ${newTime} ms`);
4866
+ return;
4867
+ }
4160
4868
  if (dragMode.kind === "appearance") {
4161
4869
  const dxPx = e.clientX - dragMode.startMouseX;
4162
4870
  const dt = Math.round(dxPx / pxPerMs);
@@ -16159,6 +16867,7 @@ var KIND_ICON = {
16159
16867
  appearance: "\u25D0",
16160
16868
  chapter: "\u{1F4CD}",
16161
16869
  effect: "\u2728",
16870
+ camera: "\u{1F3A5}",
16162
16871
  asset: "\u{1F5BC}",
16163
16872
  group: "\u2B1A",
16164
16873
  layout: "\u25A6",
@@ -16283,7 +16992,8 @@ function queryUi() {
16283
16992
  const undoBtn = $("studio-undo");
16284
16993
  const redoBtn = $("studio-redo");
16285
16994
  const gridToggleBtn = $("studio-grid-toggle");
16286
- if (!titleInput || !idDisplay || !status || !saveBtn || !exportBtn || !importBtn || !importFileInput || !deleteBtn || !newBtn || !openBtn || !playBtn || !restartBtn || !speedInput || !speedValue || !previewLoopInput || !detachTimelineBtn || !canvas || !elementList || !toolsRoot || !propsRoot || !timelineTracks || !elementTracks || !addStepBtn || !libraryDialog || !libraryList || !newDialog || !newIdInput || !newTitleInput || !newCreateBtn || !newError || !canvasWidthInput || !canvasHeightInput || !imageUploadBtn || !imageFileInput || !helpBtn || !helpDialog || !undoBtn || !redoBtn || !gridToggleBtn) {
16995
+ const cameraFrameBtn = $("studio-camera-frame-toggle");
16996
+ if (!titleInput || !idDisplay || !status || !saveBtn || !exportBtn || !importBtn || !importFileInput || !deleteBtn || !newBtn || !openBtn || !playBtn || !restartBtn || !speedInput || !speedValue || !previewLoopInput || !detachTimelineBtn || !canvas || !elementList || !toolsRoot || !propsRoot || !timelineTracks || !elementTracks || !addStepBtn || !libraryDialog || !libraryList || !newDialog || !newIdInput || !newTitleInput || !newCreateBtn || !newError || !canvasWidthInput || !canvasHeightInput || !imageUploadBtn || !imageFileInput || !helpBtn || !helpDialog || !undoBtn || !redoBtn || !gridToggleBtn || !cameraFrameBtn) {
16287
16997
  return null;
16288
16998
  }
16289
16999
  return {
@@ -16326,7 +17036,8 @@ function queryUi() {
16326
17036
  helpDialog,
16327
17037
  undoBtn,
16328
17038
  redoBtn,
16329
- gridToggleBtn
17039
+ gridToggleBtn,
17040
+ cameraFrameBtn
16330
17041
  };
16331
17042
  }
16332
17043
 
@@ -16434,6 +17145,14 @@ function reflectGridUi(ui) {
16434
17145
  const label = document.getElementById("studio-grid-label");
16435
17146
  if (label) label.textContent = on ? `\uACA9\uC790 ${size}px` : "\uACA9\uC790 \uB054";
16436
17147
  }
17148
+ function reflectCameraFrameUi(ui) {
17149
+ const on = isCameraFrameVisible();
17150
+ ui.cameraFrameBtn.setAttribute("aria-pressed", on ? "true" : "false");
17151
+ ui.cameraFrameBtn.classList.toggle("is-active", on);
17152
+ ui.cameraFrameBtn.title = on ? "\uCE74\uBA54\uB77C \uC601\uC5ED \uD45C\uC2DC \uC911 \u2014 \uCE94\uBC84\uC2A4\uC5D0 \uD604\uC7AC \uC2DC\uAC01\uC758 \uCE74\uBA54\uB77C \uC0AC\uAC01\uD615\uC744 \uADF8\uB9BD\uB2C8\uB2E4" : "\uCE74\uBA54\uB77C \uC601\uC5ED \uD45C\uC2DC\uD558\uAE30";
17153
+ const label = document.getElementById("studio-camera-frame-label");
17154
+ if (label) label.textContent = on ? "\uCE74\uBA54\uB77C \uC601\uC5ED" : "\uCE74\uBA54\uB77C \uC601\uC5ED \uB054";
17155
+ }
16437
17156
  function setupTimelineResizer(ui) {
16438
17157
  const resizer = document.getElementById("studio-timeline-resizer");
16439
17158
  if (!resizer) return;
@@ -17109,6 +17828,7 @@ function initStudio(opts = {}) {
17109
17828
  initGrid();
17110
17829
  reflectGridUi(ui);
17111
17830
  subscribeGrid(() => reflectGridUi(ui));
17831
+ reflectCameraFrameUi(ui);
17112
17832
  initCanvas(ui.canvas);
17113
17833
  initElementList(ui.elementList, ui.toolsRoot);
17114
17834
  initProperties(ui.propsRoot);
@@ -17282,6 +18002,10 @@ function initStudio(opts = {}) {
17282
18002
  "click",
17283
18003
  () => setGridEnabled(!isGridEnabled())
17284
18004
  );
18005
+ ui.cameraFrameBtn.addEventListener("click", () => {
18006
+ setCameraFrameVisible(!isCameraFrameVisible());
18007
+ reflectCameraFrameUi(ui);
18008
+ });
17285
18009
  ui.playBtn.addEventListener("click", () => togglePlay(ui));
17286
18010
  ui.restartBtn.addEventListener("click", () => {
17287
18011
  if (isPlaying()) {
@@ -17372,5 +18096,5 @@ function reflectState(ui) {
17372
18096
  }
17373
18097
 
17374
18098
  export { getVisibleElementIds, initStudio };
17375
- //# sourceMappingURL=main-SKM3P4X7.js.map
17376
- //# sourceMappingURL=main-SKM3P4X7.js.map
18099
+ //# sourceMappingURL=main-HDE5SDKM.js.map
18100
+ //# sourceMappingURL=main-HDE5SDKM.js.map