akari-video 0.1.86 → 0.1.87

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akari-video",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "AKARI Video launcher CLI — start an AI-edited video project from any directory: scaffold, connection check, then hand over to Claude Code (or opencode). AKARI Video を opencode や Claude Code で、どのディレクトリからでも始めるための `akari` ランチャー CLI。接続確認(doctor)→ 未セットアップならプロジェクト雛形を作成 → AI エージェントを起動する。外部 npm 依存ゼロ(Node.js 組み込みモジュールのみ)。 [akari-video npm vendor: bin/akari.mjs is reference-only. These CLI entrypoints are not included in the akari-video npm package. Use `akari doctor --json` and run the path reported in `render_cut.path`. Full installations provide it in a monorepo checkout, ~/.akari/app, /Applications/AKARI Video.app/Contents/Resources/packages, or %LOCALAPPDATA%\\Programs\\@akari-videoshell\\resources\\packages.]",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,7 +20,7 @@ test('tracked sources and documentation cannot reintroduce the retired domain',
20
20
  'packages/akari-launcher/src/service-urls.cjs',
21
21
  'packages/asset-resolver/test/service-urls.test.mjs',
22
22
  ]);
23
- const files = execFileSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8' }).split('\0').filter(Boolean);
23
+ const files = execFileSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(Boolean);
24
24
  const targets = files.filter(file => {
25
25
  // Evidence and changelogs record historical observations, not current defaults.
26
26
  if (/(^|\/)evidence\//.test(file) || /(^|\/)CHANGELOG[^/]*$/i.test(file)) return false;
@@ -7,7 +7,6 @@ const transform_1 = require("./transform");
7
7
  const transform_keyframe_edit_1 = require("./transform-keyframe-edit");
8
8
  const item_motion_js_1 = require("../../overlay-runtime/src/item-motion.js");
9
9
  const motion_keyframe_replace_1 = require("./motion-keyframe-replace");
10
- const motion_position_write_1 = require("./motion-position-write");
11
10
  const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
12
11
  const recordOf = (value) => isRecord(value) ? value : {};
13
12
  const stringifyEdit = (value) => `${JSON.stringify(value, undefined, 2)}\n`;
@@ -130,19 +129,10 @@ function resolveV2Write(parsed, command) {
130
129
  const seconds = command.playheadSeconds;
131
130
  const positionOnly = Object.keys(patch).length > 0
132
131
  && Object.keys(patch).every(key => key === 'x' || key === 'y');
133
- if (positionOnly) {
132
+ if (positionOnly || Number.isFinite(seconds)) {
134
133
  const start = [...target.ancestors, item].reduce((sum, entry) => sum + entry.at, 0);
135
134
  const frame = Number.isFinite(seconds)
136
135
  ? Math.max(0, Math.min(item.duration, Math.round(seconds * edit.output.fps) - start)) : 0;
137
- const updated = (0, motion_position_write_1.writeItemPositionAt)(item, frame, patch);
138
- item.transform = updated.transform;
139
- item.keyframes = updated.keyframes;
140
- return;
141
- }
142
- if (Number.isFinite(seconds) && Array.isArray(item.keyframes)
143
- && item.keyframes.some(point => point.transform)) {
144
- const start = [...target.ancestors, item].reduce((sum, entry) => sum + entry.at, 0);
145
- const frame = Math.round(seconds * edit.output.fps) - start;
146
136
  const updated = (0, transform_keyframe_edit_1.writeItemTransformAt)(item, frame, patch);
147
137
  item.transform = updated.transform;
148
138
  item.keyframes = updated.keyframes;
@@ -219,7 +209,8 @@ function resolveV2Write(parsed, command) {
219
209
  throw new Error(`グループアイテムには HTML 本文・vars・HTML params を書き戻せません: ${itemId}`);
220
210
  }
221
211
  if (command.patch.xyKeyframes)
222
- item.keyframes = (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration);
212
+ item.keyframes = (0, transform_keyframe_edit_1.normalizeItemKeyframeGroup)({ ...item,
213
+ keyframes: (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration) }, 'position').keyframes;
223
214
  if (command.patch.transform)
224
215
  writeTransform(command.patch.transform);
225
216
  if (!command.patch.transform && !command.patch.xyKeyframes)
@@ -264,13 +255,15 @@ function resolveV2Write(parsed, command) {
264
255
  editChanged = true;
265
256
  }
266
257
  if (command.patch.xyKeyframes) {
267
- item.keyframes = (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration);
258
+ item.keyframes = (0, transform_keyframe_edit_1.normalizeItemKeyframeGroup)({ ...item,
259
+ keyframes: (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration) }, 'position').keyframes;
268
260
  editChanged = true;
269
261
  }
270
262
  }
271
263
  else if (command.kind === 'layer') {
272
264
  if (command.patch.xyKeyframes) {
273
- item.keyframes = (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration);
265
+ item.keyframes = (0, transform_keyframe_edit_1.normalizeItemKeyframeGroup)({ ...item,
266
+ keyframes: (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration) }, 'position').keyframes;
274
267
  editChanged = true;
275
268
  }
276
269
  if (command.patch.transform) {
@@ -298,7 +291,8 @@ function resolveV2Write(parsed, command) {
298
291
  throw new Error(`映像アイテムではありません: ${itemId}`);
299
292
  }
300
293
  if (command.patch.xyKeyframes) {
301
- item.keyframes = (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration);
294
+ item.keyframes = (0, transform_keyframe_edit_1.normalizeItemKeyframeGroup)({ ...item,
295
+ keyframes: (0, motion_keyframe_replace_1.replaceXYKeyframes)(item.keyframes, command.patch.xyKeyframes, item.duration) }, 'position').keyframes;
302
296
  editChanged = true;
303
297
  }
304
298
  if (command.patch.transform) {
@@ -42,3 +42,16 @@ export { LegacyEditVersionError } from './migrate/error';
42
42
  export * from './adjust-css-visual';
43
43
  export { effectiveScale, normalizeTransform } from './transform';
44
44
  export * from './transform-keyframe-edit';
45
+ export declare const SAVED_BY_PATH = ".akari/saved-by.json";
46
+ export interface SavedByStamp {
47
+ version: 1;
48
+ app: 'akari-video';
49
+ appVersion: string;
50
+ savedAt: string;
51
+ }
52
+ export declare function parseSavedBy(text: string | undefined): SavedByStamp | undefined;
53
+ export declare function newerSavedByVersion(text: string | undefined, currentVersion: string | undefined): string | undefined;
54
+ export declare function isUnknownKeyEditError(error: unknown): boolean;
55
+ export declare function newerVersionOpenNotice(savedVersion: string, currentVersion: string): string;
56
+ export declare function newerVersionLintPrefix(savedVersion: string): string;
57
+ export declare function withNewerVersionLintPrefix(message: string, savedVersion?: string): string;
@@ -14,7 +14,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.normalizeTransform = exports.effectiveScale = exports.LegacyEditVersionError = exports.parseEdit = void 0;
17
+ exports.SAVED_BY_PATH = exports.normalizeTransform = exports.effectiveScale = exports.LegacyEditVersionError = exports.parseEdit = void 0;
18
+ exports.parseSavedBy = parseSavedBy;
19
+ exports.newerSavedByVersion = newerSavedByVersion;
20
+ exports.isUnknownKeyEditError = isUnknownKeyEditError;
21
+ exports.newerVersionOpenNotice = newerVersionOpenNotice;
22
+ exports.newerVersionLintPrefix = newerVersionLintPrefix;
23
+ exports.withNewerVersionLintPrefix = withNewerVersionLintPrefix;
18
24
  /**
19
25
  * ブラウザ安全なエントリポイント(テキスト手術のみ)。
20
26
  * lint ゲート付き書き込み(Node 専用)は './write-gate' を明示的に import すること
@@ -67,3 +73,56 @@ var transform_1 = require("./transform");
67
73
  Object.defineProperty(exports, "effectiveScale", { enumerable: true, get: function () { return transform_1.effectiveScale; } });
68
74
  Object.defineProperty(exports, "normalizeTransform", { enumerable: true, get: function () { return transform_1.normalizeTransform; } });
69
75
  __exportStar(require("./transform-keyframe-edit"), exports);
76
+ // write-gate.ts の SAVED_BY_PATH と同値に保つ。
77
+ exports.SAVED_BY_PATH = '.akari/saved-by.json';
78
+ const SAVED_BY_SCHEMA_VERSION = 1;
79
+ function parseSavedBy(text) {
80
+ if (!text)
81
+ return undefined;
82
+ try {
83
+ const value = JSON.parse(text);
84
+ if (!value || typeof value !== 'object')
85
+ return undefined;
86
+ const stamp = value;
87
+ return stamp.version === SAVED_BY_SCHEMA_VERSION && stamp.app === 'akari-video'
88
+ && typeof stamp.appVersion === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(stamp.appVersion)
89
+ && typeof stamp.savedAt === 'string' && !Number.isNaN(Date.parse(stamp.savedAt))
90
+ ? stamp : undefined;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ function compareSavedByVersions(left, right) {
97
+ const leftParts = left.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
98
+ const rightParts = right.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
99
+ if (!leftParts || !rightParts)
100
+ return 0;
101
+ for (let i = 1; i <= 3; i++) {
102
+ const a = Number(leftParts[i]);
103
+ const b = Number(rightParts[i]);
104
+ if (a !== b)
105
+ return a < b ? -1 : 1;
106
+ }
107
+ return 0;
108
+ }
109
+ function newerSavedByVersion(text, currentVersion) {
110
+ const savedVersion = parseSavedBy(text)?.appVersion;
111
+ return savedVersion && currentVersion && compareSavedByVersions(savedVersion, currentVersion) > 0
112
+ ? savedVersion : undefined;
113
+ }
114
+ function isUnknownKeyEditError(error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ return /edit\.json[^\n]*未定義キーを使用できません/.test(message);
117
+ }
118
+ function newerVersionOpenNotice(savedVersion, currentVersion) {
119
+ return `このプロジェクトは新しい版の AKARI Video(v${savedVersion})で保存されています。`
120
+ + `いまの版(v${currentVersion})では開けない機能が使われています。AKARI Video を更新してください。`;
121
+ }
122
+ function newerVersionLintPrefix(savedVersion) {
123
+ return `このプロジェクトは新しい版(v${savedVersion})で保存されています。`
124
+ + 'いまの版の検証は新しい機能を知らないため、誤ってエラーを出すことがあります。';
125
+ }
126
+ function withNewerVersionLintPrefix(message, savedVersion) {
127
+ return savedVersion ? `${newerVersionLintPrefix(savedVersion)} ${message}` : message;
128
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.writeItemPositionAt = writeItemPositionAt;
4
+ const transform_keyframe_edit_1 = require("./transform-keyframe-edit");
4
5
  /** A direct move writes only the requested position axes at the item-local frame. */
5
6
  function writeItemPositionAt(item, frame, position) {
6
7
  if (!Number.isInteger(frame) || frame < 0 || frame > item.duration
@@ -9,18 +10,5 @@ function writeItemPositionAt(item, frame, position) {
9
10
  || !Number.isFinite(position[axis]))) {
10
11
  throw new Error('位置または時刻が正しくありません');
11
12
  }
12
- const copy = structuredClone(item);
13
- if (Array.isArray(copy.keyframes) && copy.keyframes.length >= 2) {
14
- let point = copy.keyframes.find(entry => entry.t === frame);
15
- if (!point) {
16
- point = { t: frame };
17
- copy.keyframes.push(point);
18
- copy.keyframes.sort((left, right) => left.t - right.t);
19
- }
20
- point.transform = { ...point.transform, ...position };
21
- }
22
- else {
23
- copy.transform = { ...copy.transform, ...position };
24
- }
25
- return copy;
13
+ return (0, transform_keyframe_edit_1.writeItemTransformAt)(item, frame, position);
26
14
  }
@@ -2,8 +2,23 @@ import type { ItemV2, TransformV2 } from './edit-v2';
2
2
  export type TransformField = 'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' | 'rotate';
3
3
  /** Effective local pose, in pixels, unitless scale factors, and degrees. */
4
4
  export declare function evaluatedItemTransform(item: Pick<ItemV2, 'source' | 'transform' | 'keyframes' | 'duration'>, frame: number): Required<TransformV2>;
5
+ export type ItemKeyframeGroup = 'position' | 'size' | 'rotation' | 'opacity';
6
+ export declare function hasItemKeyframeGroup(item: Pick<ItemV2, 'keyframes'>, group: ItemKeyframeGroup): boolean;
5
7
  export declare function hasTransformKeyframe(item: Pick<ItemV2, 'keyframes'>, field: TransformField): boolean;
6
- /** Toggle-on: seed the currently visible pose; media points are complete because the cut evaluator replaces a whole transform. */
8
+ /** The same declared-point/hold/interpolation rule as the transform evaluator. */
9
+ export declare function evaluatedItemOpacity(item: Pick<ItemV2, 'opacity' | 'keyframes' | 'duration'>, frame: number): number;
10
+ /** Backfill old sparse points only when this group is written. Values are read before mutation. */
11
+ export declare function normalizeItemKeyframeGroup(item: ItemV2, group: ItemKeyframeGroup): ItemV2;
12
+ /** Add one visible-pose point. A lone group point also becomes its static value. */
13
+ export declare function activateItemKeyframeGroup(item: ItemV2, frame: number, group: ItemKeyframeGroup): ItemV2;
14
+ /** Item-wide diamond: all four groups share one playhead point and one item result. */
15
+ export declare function activateItemKeyframe(item: ItemV2, frame: number): ItemV2;
7
16
  export declare function activateItemTransformKeyframe(item: ItemV2, frame: number, field: TransformField): ItemV2;
8
- /** One gesture produces one updated item; only animated coordinates receive a point at the playhead. */
17
+ /** A gesture returns one updated item; every animated group auto-keys at the playhead. */
9
18
  export declare function writeItemTransformAt(item: ItemV2, frame: number, input: TransformV2): ItemV2;
19
+ export declare function writeItemOpacityAt(item: ItemV2, frame: number, opacity: number): ItemV2;
20
+ /** Removing the last group point freezes the pose visible immediately before deletion. */
21
+ export declare function removeItemKeyframeGroup(item: ItemV2, frame: number, group: ItemKeyframeGroup): ItemV2;
22
+ /** Timeline point deletion is deliberately whole-point, independent of the selected row. */
23
+ export declare function removeItemKeyframePoint(item: ItemV2, frame: number): ItemV2;
24
+ export declare function moveItemKeyframeGroup(item: ItemV2, fromFrame: number, toFrame: number, group: ItemKeyframeGroup): ItemV2;
@@ -1,11 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.evaluatedItemTransform = evaluatedItemTransform;
4
+ exports.hasItemKeyframeGroup = hasItemKeyframeGroup;
4
5
  exports.hasTransformKeyframe = hasTransformKeyframe;
6
+ exports.evaluatedItemOpacity = evaluatedItemOpacity;
7
+ exports.normalizeItemKeyframeGroup = normalizeItemKeyframeGroup;
8
+ exports.activateItemKeyframeGroup = activateItemKeyframeGroup;
9
+ exports.activateItemKeyframe = activateItemKeyframe;
5
10
  exports.activateItemTransformKeyframe = activateItemTransformKeyframe;
6
11
  exports.writeItemTransformAt = writeItemTransformAt;
12
+ exports.writeItemOpacityAt = writeItemOpacityAt;
13
+ exports.removeItemKeyframeGroup = removeItemKeyframeGroup;
14
+ exports.removeItemKeyframePoint = removeItemKeyframePoint;
15
+ exports.moveItemKeyframeGroup = moveItemKeyframeGroup;
7
16
  const FIELDS = ['x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate'];
8
- const DEFAULTS = { x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 };
9
17
  const finite = (value) => typeof value === 'number' && Number.isFinite(value);
10
18
  const isMedia = (item) => item.source.kind === 'media';
11
19
  const pointsOf = (item) => Array.isArray(item.keyframes) ? item.keyframes.slice().sort((a, b) => a.t - b.t) : [];
@@ -92,8 +100,6 @@ function valueAt(points, frame, field, fallback, media, statics) {
92
100
  if (!finite(value) && (field === 'scaleX' || field === 'scaleY')) {
93
101
  value = transform.scale ?? (media ? statics[field] ?? statics.scale ?? 1 : fallback);
94
102
  }
95
- if (!finite(value) && media)
96
- value = DEFAULTS[field];
97
103
  return finite(value) ? [{ point, value }] : [];
98
104
  });
99
105
  if (!declared.length)
@@ -122,15 +128,59 @@ function evaluatedItemTransform(item, frame) {
122
128
  scaleY: staticValue.scaleY ?? staticValue.scale ?? 1, rotate: staticValue.rotate ?? 0
123
129
  };
124
130
  const points = pointsOf(item), media = isMedia(item);
125
- if (points.length < 2 || !points.some(point => point.transform))
131
+ if (!points.some(point => point.transform))
126
132
  return base;
127
133
  const at = frameOf(item, frame);
128
134
  return Object.fromEntries(FIELDS.map(field => [field,
129
- valueAt(points, at, field, media && field !== 'scaleX' && field !== 'scaleY'
130
- ? DEFAULTS[field] : base[field], media, staticValue)]));
135
+ valueAt(points, at, field, base[field], media, staticValue)]));
136
+ }
137
+ const GROUP_FIELDS = {
138
+ position: ['x', 'y'], size: ['scale', 'scaleX', 'scaleY'], rotation: ['rotate']
139
+ };
140
+ const groupOf = (field) => field === 'x' || field === 'y' ? 'position'
141
+ : field === 'rotate' ? 'rotation' : 'size';
142
+ const declares = (point, group) => group === 'opacity'
143
+ ? finite(point.opacity) : GROUP_FIELDS[group].some(field => finite(point.transform?.[field]));
144
+ const groupPoints = (item, group) => pointsOf(item).filter(point => declares(point, group));
145
+ const hasPointValue = (point) => Object.entries(point)
146
+ .some(([key, value]) => key !== 't' && key !== 'easing' && value !== undefined);
147
+ function legalPointArray(item, points) {
148
+ const meaningful = points.filter(hasPointValue).sort((a, b) => a.t - b.t);
149
+ if (!meaningful.length)
150
+ return undefined;
151
+ if (meaningful.length > 1)
152
+ return meaningful;
153
+ // The existing file schema requires two array entries. This empty entry has no animated
154
+ // property; every evaluator and diamond list ignores it.
155
+ const t = meaningful[0].t < item.duration ? meaningful[0].t + 1 : meaningful[0].t - 1;
156
+ return [...meaningful, { t }].sort((a, b) => a.t - b.t);
157
+ }
158
+ function hasItemKeyframeGroup(item, group) {
159
+ return groupPoints(item, group).length > 0;
131
160
  }
132
161
  function hasTransformKeyframe(item, field) {
133
- return pointsOf(item).some(point => finite(point.transform?.[field]));
162
+ return hasItemKeyframeGroup(item, groupOf(field));
163
+ }
164
+ /** The same declared-point/hold/interpolation rule as the transform evaluator. */
165
+ function evaluatedItemOpacity(item, frame) {
166
+ const points = groupPoints(item, 'opacity');
167
+ const fallback = item.opacity ?? 1;
168
+ if (!points.length)
169
+ return fallback;
170
+ const at = frameOf(item, frame);
171
+ if (at <= points[0].t)
172
+ return points[0].opacity;
173
+ const last = points[points.length - 1];
174
+ if (at >= last.t)
175
+ return last.opacity;
176
+ for (let index = 1; index < points.length; index++) {
177
+ const right = points[index], left = points[index - 1];
178
+ if (at > right.t)
179
+ continue;
180
+ const u = eased(right, 'opacity', (at - left.t) / (right.t - left.t || 1), false);
181
+ return left.opacity + (right.opacity - left.opacity) * u;
182
+ }
183
+ return fallback;
134
184
  }
135
185
  function validPatch(patch) {
136
186
  for (const [field, value] of Object.entries(patch)) {
@@ -147,76 +197,166 @@ function normalizedAxisPatch(current, patch) {
147
197
  const ratio = previous > 0 ? patch.scale / previous : 1;
148
198
  return { ...patch, scaleX: current.scaleX * ratio, scaleY: current.scaleY * ratio };
149
199
  }
200
+ function valuesAt(item, frame, group) {
201
+ if (group === 'opacity')
202
+ return evaluatedItemOpacity(item, frame);
203
+ const pose = evaluatedItemTransform(item, frame);
204
+ if (group === 'position')
205
+ return { x: pose.x, y: pose.y };
206
+ if (group === 'rotation')
207
+ return { rotate: pose.rotate };
208
+ return { scale: pose.scale, scaleX: pose.scaleX, scaleY: pose.scaleY };
209
+ }
210
+ function withStaticGroup(item, group, value) {
211
+ if (group === 'opacity')
212
+ return { ...item, opacity: value };
213
+ return { ...item, transform: { ...item.transform, ...value } };
214
+ }
215
+ /** Backfill old sparse points only when this group is written. Values are read before mutation. */
216
+ function normalizeItemKeyframeGroup(item, group) {
217
+ const keyframes = pointsOf(item).map(point => {
218
+ const copy = structuredClone(point);
219
+ if (!declares(point, group))
220
+ return copy;
221
+ const value = valuesAt(item, point.t, group);
222
+ if (group === 'opacity')
223
+ copy.opacity = value;
224
+ else
225
+ copy.transform = { ...copy.transform, ...value };
226
+ return copy;
227
+ });
228
+ return keyframes.length ? { ...item, keyframes } : item;
229
+ }
150
230
  function fullMediaPoint(item, point) {
151
231
  if (!point.transform)
152
232
  return { ...point };
153
233
  return { ...point, transform: evaluatedItemTransform(item, point.t) };
154
234
  }
155
- /** Toggle-on: seed the currently visible pose; media points are complete because the cut evaluator replaces a whole transform. */
156
- function activateItemTransformKeyframe(item, frame, field) {
157
- const at = frameOf(item, frame), before = evaluatedItemTransform(item, at);
158
- const points = pointsOf(item);
159
- if (hasTransformKeyframe(item, field) && points.some(point => point.t === at && finite(point.transform?.[field])))
160
- return item;
161
- const next = points.map(point => isMedia(item) ? fullMediaPoint(item, point) : { ...point });
162
- const value = isMedia(item) ? { ...before } : field === 'scale'
163
- ? { scale: Math.sqrt(before.scaleX * before.scaleY), scaleX: before.scaleX, scaleY: before.scaleY }
164
- : { [field]: before[field] };
165
- const seat = next.find(point => point.t === at);
166
- if (seat)
167
- seat.transform = { ...seat.transform, ...value };
168
- else
169
- next.push({ t: at, transform: value });
170
- if (next.length === 1) {
171
- next.push({ t: at === 0 ? item.duration : 0, transform: { ...value } });
235
+ function addGroupPoint(item, frame, group, value) {
236
+ const at = frameOf(item, frame);
237
+ const normalized = normalizeItemKeyframeGroup(item, group);
238
+ const keyframes = pointsOf(normalized).map(point => isMedia(item) ? fullMediaPoint(normalized, point) : structuredClone(point));
239
+ let seat = keyframes.find(point => point.t === at);
240
+ if (!seat) {
241
+ seat = { t: at };
242
+ keyframes.push(seat);
172
243
  }
173
- return { ...item, keyframes: next.sort((a, b) => a.t - b.t) };
244
+ if (group === 'opacity') {
245
+ seat.opacity = value;
246
+ if (isMedia(item))
247
+ seat.transform = evaluatedItemTransform(item, at);
248
+ }
249
+ else
250
+ seat.transform = { ...(isMedia(item) ? evaluatedItemTransform(item, at) : seat.transform),
251
+ ...value };
252
+ return { ...normalized, keyframes: legalPointArray(item, keyframes) };
253
+ }
254
+ /** Add one visible-pose point. A lone group point also becomes its static value. */
255
+ function activateItemKeyframeGroup(item, frame, group) {
256
+ const at = frameOf(item, frame);
257
+ const hadGroup = hasItemKeyframeGroup(item, group);
258
+ const value = valuesAt(item, at, group);
259
+ if (groupPoints(item, group).some(point => point.t === at))
260
+ return normalizeItemKeyframeGroup(item, group);
261
+ const updated = addGroupPoint(item, at, group, value);
262
+ return hadGroup ? updated : withStaticGroup(updated, group, value);
263
+ }
264
+ /** Item-wide diamond: all four groups share one playhead point and one item result. */
265
+ function activateItemKeyframe(item, frame) {
266
+ return ['position', 'size', 'rotation', 'opacity']
267
+ .reduce((current, group) => activateItemKeyframeGroup(current, frame, group), item);
174
268
  }
175
- /** One gesture produces one updated item; only animated coordinates receive a point at the playhead. */
269
+ function activateItemTransformKeyframe(item, frame, field) {
270
+ return activateItemKeyframeGroup(item, frame, groupOf(field));
271
+ }
272
+ function patchedGroupValue(item, frame, group, patch) {
273
+ const current = evaluatedItemTransform(item, frame);
274
+ if (group === 'position')
275
+ return { x: patch.x ?? current.x, y: patch.y ?? current.y };
276
+ if (group === 'rotation')
277
+ return { rotate: patch.rotate ?? current.rotate };
278
+ const adjusted = normalizedAxisPatch(current, patch);
279
+ const scaleX = adjusted.scaleX ?? current.scaleX, scaleY = adjusted.scaleY ?? current.scaleY;
280
+ return { scale: Math.sqrt(scaleX * scaleY), scaleX, scaleY };
281
+ }
282
+ function writeGroup(item, frame, group, patch) {
283
+ const value = patchedGroupValue(item, frame, group, patch);
284
+ if (!hasItemKeyframeGroup(item, group))
285
+ return withStaticGroup(item, group, value);
286
+ const initialPoint = groupPoints(item, group)[0];
287
+ const updated = addGroupPoint(item, frame, group, value);
288
+ return groupPoints(item, group).length === 1
289
+ ? withStaticGroup(updated, group, valuesAt(updated, initialPoint.t, group)) : updated;
290
+ }
291
+ /** A gesture returns one updated item; every animated group auto-keys at the playhead. */
176
292
  function writeItemTransformAt(item, frame, input) {
177
293
  validPatch(input);
178
- const at = frameOf(item, frame), current = evaluatedItemTransform(item, at);
179
- const patch = normalizedAxisPatch(current, input);
180
- const animated = new Set(FIELDS.filter(field => hasTransformKeyframe(item, field)));
181
- if (patch.scale !== undefined && (animated.has('scaleX') || animated.has('scaleY')))
182
- animated.add('scale');
183
- if (animated.has('scale')) {
184
- animated.add('scaleX');
185
- animated.add('scaleY');
186
- }
187
- const base = { ...item.transform };
188
- const pointPatch = {};
189
- for (const field of FIELDS) {
190
- const value = patch[field];
191
- if (value === undefined)
192
- continue;
193
- if (animated.has(field))
194
- pointPatch[field] = value;
195
- else
196
- base[field] = value;
197
- }
198
- if (patch.scaleX !== undefined || patch.scaleY !== undefined) {
199
- if (animated.has('scale')) {
200
- pointPatch.scaleX ??= current.scaleX;
201
- pointPatch.scaleY ??= current.scaleY;
294
+ return [...new Set(Object.keys(input).map(field => groupOf(field)))].reduce((current, group) => writeGroup(current, frame, group, input), item);
295
+ }
296
+ function writeItemOpacityAt(item, frame, opacity) {
297
+ if (!finite(opacity) || opacity < 0 || opacity > 1)
298
+ throw new Error('Invalid opacity');
299
+ if (!hasItemKeyframeGroup(item, 'opacity'))
300
+ return withStaticGroup(item, 'opacity', opacity);
301
+ const initialPoint = groupPoints(item, 'opacity')[0];
302
+ const updated = addGroupPoint(item, frame, 'opacity', opacity);
303
+ return groupPoints(item, 'opacity').length === 1
304
+ ? withStaticGroup(updated, 'opacity', valuesAt(updated, initialPoint.t, 'opacity')) : updated;
305
+ }
306
+ /** Removing the last group point freezes the pose visible immediately before deletion. */
307
+ function removeItemKeyframeGroup(item, frame, group) {
308
+ const at = frameOf(item, frame);
309
+ if (!groupPoints(item, group).some(point => point.t === at))
310
+ return item;
311
+ const before = valuesAt(item, at, group);
312
+ const normalized = normalizeItemKeyframeGroup(item, group);
313
+ const keyframes = pointsOf(normalized).map(point => {
314
+ const copy = structuredClone(point);
315
+ if (copy.t !== at)
316
+ return copy;
317
+ if (group === 'opacity')
318
+ delete copy.opacity;
319
+ else if (copy.transform) {
320
+ for (const field of GROUP_FIELDS[group])
321
+ delete copy.transform[field];
322
+ if (!Object.keys(copy.transform).length)
323
+ delete copy.transform;
202
324
  }
203
- const x = pointPatch.scaleX ?? base.scaleX ?? base.scale ?? current.scaleX;
204
- const y = pointPatch.scaleY ?? base.scaleY ?? base.scale ?? current.scaleY;
205
- if (animated.has('scale'))
206
- pointPatch.scale = Math.sqrt(x * y);
207
- else if (base.scaleX !== undefined || base.scaleY !== undefined)
208
- base.scale = Math.sqrt(x * y);
209
- }
210
- let keyframes = pointsOf(item);
211
- if (Object.keys(pointPatch).length) {
212
- keyframes = keyframes.map(point => isMedia(item) ? fullMediaPoint(item, point) : { ...point });
213
- let seat = keyframes.find(point => point.t === at);
214
- if (!seat) {
215
- seat = { t: at };
216
- keyframes.push(seat);
325
+ return copy;
326
+ }).filter(point => point.transform || point.opacity !== undefined || point.crop || point.perspective
327
+ || point.animator || point.gain_db !== undefined);
328
+ let updated = { ...normalized, keyframes: legalPointArray(item, keyframes) };
329
+ const remaining = groupPoints(updated, group);
330
+ if (!remaining.length)
331
+ updated = withStaticGroup(updated, group, before);
332
+ else if (remaining.length === 1)
333
+ updated = withStaticGroup(updated, group, valuesAt(normalized, remaining[0].t, group));
334
+ return updated;
335
+ }
336
+ /** Timeline point deletion is deliberately whole-point, independent of the selected row. */
337
+ function removeItemKeyframePoint(item, frame) {
338
+ const at = frameOf(item, frame);
339
+ const point = pointsOf(item).find(entry => entry.t === at);
340
+ if (!point)
341
+ return item;
342
+ const values = ['position', 'size', 'rotation', 'opacity']
343
+ .filter(group => declares(point, group)).map(group => [group, valuesAt(item, at, group)]);
344
+ const keyframes = pointsOf(item).filter(entry => entry.t !== at).map(entry => structuredClone(entry));
345
+ let updated = { ...item, keyframes: legalPointArray(item, keyframes) };
346
+ for (const [group, value] of values)
347
+ if (!hasItemKeyframeGroup(updated, group)) {
348
+ updated = withStaticGroup(updated, group, value);
217
349
  }
218
- seat.transform = { ...(isMedia(item) ? current : seat.transform), ...pointPatch };
219
- keyframes.sort((a, b) => a.t - b.t);
220
- }
221
- return { ...item, transform: base, ...(keyframes.length ? { keyframes } : {}) };
350
+ return updated;
351
+ }
352
+ function moveItemKeyframeGroup(item, fromFrame, toFrame, group) {
353
+ const from = frameOf(item, fromFrame), to = frameOf(item, toFrame);
354
+ if (from === to)
355
+ return item;
356
+ const point = groupPoints(item, group).find(entry => entry.t === from);
357
+ if (!point)
358
+ return item;
359
+ const value = valuesAt(item, from, group);
360
+ const removed = removeItemKeyframeGroup(item, from, group);
361
+ return addGroupPoint(removed, to, group, value);
222
362
  }
@@ -16,6 +16,7 @@
16
16
  * 編集不能より lint なし保存の方が被害が小さいという判断(型不正は各書き込みの
17
17
  * ローカル検証が別途残るため安全側は保たれる)。
18
18
  */
19
+ export declare const SAVED_BY_PATH = ".akari/saved-by.json";
19
20
  export interface EditLintFinding {
20
21
  severity?: string;
21
22
  message?: string;
@@ -30,6 +31,8 @@ export interface EditLintGateResult {
30
31
  /** 候補ファイル名(プロジェクト直下からの相対パス)→ 書き込み予定の全文。null は不在扱い。 */
31
32
  export type LintCandidates = Record<string, string | null>;
32
33
  export interface DeferredLintOptions {
34
+ /** edit.json を保存した AKARI Video の版。不明なら既存スタンプを消す。 */
35
+ appVersion?: string;
33
36
  debounceMs?: number;
34
37
  onLintResult?: (result: EditLintGateResult) => void | Promise<void>;
35
38
  /** Deterministic test seam; production callers use runEditLint. */
@@ -77,6 +80,10 @@ export declare function lintProjectCandidates(projectRoot: string, candidates: L
77
80
  export declare function lintProjectCandidatesOnDisk(projectRoot: string, candidates: LintCandidates, hooks?: ShadowLintHooks): Promise<EditLintGateResult>;
78
81
  /** 互換 API。保存後 lint への移行後も、明示的に検証したい呼び出し側向けに残す。 */
79
82
  export declare function assertLintPasses(projectRoot: string, candidates: LintCandidates): Promise<void>;
83
+ /** CLI / preview-server 等のプロセス内で書き手の版を 1 回だけ設定する。 */
84
+ export declare function setDefaultSavedByAppVersion(version: string | undefined): void;
85
+ /** CLI の既存 edit.json 保存直後に、スタンプだけを atomic に更新する。 */
86
+ export declare function writeSavedByStamp(projectRoot: string, appVersion: string | undefined): Promise<void>;
80
87
  /** atomic 保存を即時完了し、lint は末尾 debounce で非同期に実行する。 */
81
88
  export declare function writeProjectFilesGuarded(projectRoot: string, candidates: LintCandidates, options?: DeferredLintOptions): Promise<void>;
82
89
  /** Web UI 旧版が生成した camelCase は schema が閉じていない legacy edit でも保存させない。 */
@@ -18,9 +18,12 @@
18
18
  * ローカル検証が別途残るため安全側は保たれる)。
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.SAVED_BY_PATH = void 0;
21
22
  exports.lintProjectCandidates = lintProjectCandidates;
22
23
  exports.lintProjectCandidatesOnDisk = lintProjectCandidatesOnDisk;
23
24
  exports.assertLintPasses = assertLintPasses;
25
+ exports.setDefaultSavedByAppVersion = setDefaultSavedByAppVersion;
26
+ exports.writeSavedByStamp = writeSavedByStamp;
24
27
  exports.writeProjectFilesGuarded = writeProjectFilesGuarded;
25
28
  exports.assertNoCamelCaseTransitionOut = assertNoCamelCaseTransitionOut;
26
29
  exports.scheduleProjectLint = scheduleProjectLint;
@@ -32,6 +35,16 @@ const fs_1 = require("fs");
32
35
  const path_1 = require("path");
33
36
  const url_1 = require("url");
34
37
  const os_1 = require("os");
38
+ // index.ts の SAVED_BY_PATH と同値に保つ(Node 専用入口は index を import しない)。
39
+ exports.SAVED_BY_PATH = '.akari/saved-by.json';
40
+ const SAVED_BY_SCHEMA_VERSION = 1;
41
+ const APP_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
42
+ function isValidSavedByAppVersion(version) {
43
+ return typeof version === 'string' && APP_VERSION_PATTERN.test(version);
44
+ }
45
+ function serializeSavedByStamp(appVersion, savedAt = new Date().toISOString()) {
46
+ return `${JSON.stringify({ version: SAVED_BY_SCHEMA_VERSION, app: 'akari-video', appVersion, savedAt }, null, 2)}\n`;
47
+ }
35
48
  /**
36
49
  * 「OS / ファイルシステムがリンク作成自体を拒んだ」エラーコード。入力が誤っている系
37
50
  * (EEXIST・ENOENT・ENOTDIR 等)は含めない — それらは従来どおり throw して原因を隠さない。
@@ -183,6 +196,22 @@ async function assertLintPasses(projectRoot, candidates) {
183
196
  throw new Error(result.errors[0] ?? 'edit-lint が変更を拒否しました');
184
197
  }
185
198
  }
199
+ let defaultSavedByAppVersion;
200
+ /** CLI / preview-server 等のプロセス内で書き手の版を 1 回だけ設定する。 */
201
+ function setDefaultSavedByAppVersion(version) {
202
+ defaultSavedByAppVersion = isValidSavedByAppVersion(version) ? version : undefined;
203
+ }
204
+ /** CLI の既存 edit.json 保存直後に、スタンプだけを atomic に更新する。 */
205
+ async function writeSavedByStamp(projectRoot, appVersion) {
206
+ const destination = (0, path_1.join)(projectRoot, exports.SAVED_BY_PATH);
207
+ if (isValidSavedByAppVersion(appVersion)) {
208
+ await writeAtomic(destination, serializeSavedByStamp(appVersion));
209
+ }
210
+ else {
211
+ // 版不明の書き手は、以前の書き手の版を主張できない。
212
+ await fs_1.promises.rm(destination, { force: true });
213
+ }
214
+ }
186
215
  /** atomic 保存を即時完了し、lint は末尾 debounce で非同期に実行する。 */
187
216
  async function writeProjectFilesGuarded(projectRoot, candidates, options = {}) {
188
217
  const editCandidate = candidates['edit.json'];
@@ -195,7 +224,17 @@ async function writeProjectFilesGuarded(projectRoot, candidates, options = {}) {
195
224
  }
196
225
  const destination = (0, path_1.join)(projectRoot, name);
197
226
  await writeAtomic(destination, text);
198
- // rename 完了直後に同期で通知する。lint スケジュールより前に出すことで、
227
+ if (name === 'edit.json') {
228
+ const version = options.appVersion ?? defaultSavedByAppVersion;
229
+ if (isValidSavedByAppVersion(version)) {
230
+ await writeAtomic((0, path_1.join)(projectRoot, exports.SAVED_BY_PATH), serializeSavedByStamp(version));
231
+ }
232
+ else {
233
+ // An unknown writer cannot keep a previous writer's version claim.
234
+ await fs_1.promises.rm((0, path_1.join)(projectRoot, exports.SAVED_BY_PATH), { force: true });
235
+ }
236
+ }
237
+ // edit.json とスタンプの rename が完了したら同期で通知する。lint スケジュールより前に出すことで、
199
238
  // 購読側が watcher(実測 42〜1183ms のばらつき)を待たずに済む。
200
239
  if (options.onDidWrite) {
201
240
  try {
@@ -3,6 +3,21 @@
3
3
  window.akari = window.akari || {};
4
4
 
5
5
  window.akari.interaction = (() => {
6
+ // A pointer gesture belongs to one controller at a time.
7
+ function canBeginPointerInteraction(owner) { return owner == null; }
8
+ let pointerOwner = null;
9
+ function setPointerOwner(owner) {
10
+ if (owner == null) { pointerOwner = null; return; }
11
+ if (pointerOwner != null && pointerOwner !== owner) return false;
12
+ pointerOwner = owner;
13
+ return true;
14
+ }
15
+ function releasePointerOwner(owner) {
16
+ if (pointerOwner !== owner) return false;
17
+ pointerOwner = null;
18
+ return true;
19
+ }
20
+
6
21
  // BEGIN selection-scope
7
22
  function lineage(tree, id) {
8
23
  const nodes = new Map(tree.map(node => [node.id, node]));
@@ -144,6 +159,20 @@ function marqueeHits(candidates, rect) {
144
159
  let activeDrag = null;
145
160
  let activeResize = null;
146
161
  let activeRotate = null;
162
+ function reportLiveValues(id, values, clear = false) {
163
+ if (!id || typeof window.akari?.reportLiveValues !== 'function') return;
164
+ window.akari.reportLiveValues({ id, ...(clear ? { clear: true } : { values }) });
165
+ }
166
+ function reportLivePose(gesture) {
167
+ if (!gesture || !gesture.moved) return;
168
+ const pose = gesture.group
169
+ ? gesture.pose ?? { ...gesture.transform, x: gesture.startX + gesture.dx,
170
+ y: gesture.startY + gesture.dy }
171
+ : readTransform(gesture.container);
172
+ reportLiveValues(gesture.overlayId, { x: pose.x, y: pose.y,
173
+ scale: pose.scale, scaleX: pose.scaleX ?? pose.scale,
174
+ scaleY: pose.scaleY ?? pose.scale, rotate: pose.rotate });
175
+ }
147
176
  let activeLine = null;
148
177
  let rotationBadge = null;
149
178
  let handleHint = null;
@@ -1588,6 +1617,7 @@ function marqueeHits(candidates, rect) {
1588
1617
  if (!activeDrag) return;
1589
1618
 
1590
1619
  const drag = activeDrag;
1620
+ reportLiveValues(drag.overlayId, undefined, true);
1591
1621
  activeDrag = null;
1592
1622
  if (drag.group) {
1593
1623
  moveGroupMembers(drag, 0, 0);
@@ -2275,6 +2305,7 @@ function marqueeHits(candidates, rect) {
2275
2305
  function cancelRotate() {
2276
2306
  if (!activeRotate) return;
2277
2307
  const rotation = activeRotate;
2308
+ reportLiveValues(rotation.overlayId, undefined, true);
2278
2309
  activeRotate = null;
2279
2310
  rotationBadge?.remove(); rotationBadge = null;
2280
2311
  document.body.style.cursor = '';
@@ -2620,6 +2651,7 @@ function marqueeHits(candidates, rect) {
2620
2651
  if (!activeResize) return;
2621
2652
 
2622
2653
  const resize = activeResize;
2654
+ reportLiveValues(resize.overlayId, undefined, true);
2623
2655
  activeResize = null;
2624
2656
  handleHint?.remove(); handleHint = null;
2625
2657
  if (resize.group) {
@@ -2695,7 +2727,7 @@ function marqueeHits(candidates, rect) {
2695
2727
  }
2696
2728
 
2697
2729
  function onPointerDown(event) {
2698
- if (!interactionEnabled) return;
2730
+ if (!interactionEnabled || !canBeginPointerInteraction(pointerOwner)) return;
2699
2731
  if (event.button !== 0 || activeDrag || activeResize || activeRotate || activeLine) return;
2700
2732
  if (selectedId && stage && event.target instanceof Element) {
2701
2733
  const bounds = stage.getBoundingClientRect();
@@ -2860,10 +2892,12 @@ function marqueeHits(candidates, rect) {
2860
2892
  }
2861
2893
  if (activeRotate && event.pointerId === activeRotate.pointerId) {
2862
2894
  updateRotate(event);
2895
+ reportLivePose(activeRotate);
2863
2896
  return;
2864
2897
  }
2865
2898
  if (activeResize && event.pointerId === activeResize.pointerId) {
2866
2899
  updateResize(event);
2900
+ reportLivePose(activeResize);
2867
2901
  return;
2868
2902
  }
2869
2903
 
@@ -2903,6 +2937,7 @@ function marqueeHits(candidates, rect) {
2903
2937
  ? { x: videoDeltaX, y: 0 }
2904
2938
  : event.shiftKey ? { x: 0, y: videoDeltaY } : { x: videoDeltaX, y: videoDeltaY });
2905
2939
  moveGroupDrag(drag, locked.x, locked.y, event.metaKey || event.ctrlKey, lockedAxis);
2940
+ reportLivePose(drag);
2906
2941
  if (event.cancelable) event.preventDefault();
2907
2942
  return;
2908
2943
  }
@@ -2917,6 +2952,7 @@ function marqueeHits(candidates, rect) {
2917
2952
  event.metaKey || event.ctrlKey,
2918
2953
  lockedAxis
2919
2954
  );
2955
+ reportLivePose(drag);
2920
2956
 
2921
2957
  if (event.cancelable) event.preventDefault();
2922
2958
  }
@@ -3374,6 +3410,7 @@ function marqueeHits(candidates, rect) {
3374
3410
  }
3375
3411
 
3376
3412
  function onKeyDown(event) {
3413
+ if (!canBeginPointerInteraction(pointerOwner)) return;
3377
3414
  if (event.isComposing) return;
3378
3415
  if (handleNudge(event)) return;
3379
3416
  if (selectionTree().length) {
@@ -3825,6 +3862,11 @@ function marqueeHits(candidates, rect) {
3825
3862
  }
3826
3863
 
3827
3864
  return {
3865
+ get pointerOwner() { return pointerOwner; },
3866
+ get activePointerOperation() { return Boolean(activeDrag || activeResize || activeRotate || activeLine || marqueeFrame); },
3867
+ canBeginPointerInteraction,
3868
+ setPointerOwner,
3869
+ releasePointerOwner,
3828
3870
  get selectedId() { return selectedId; },
3829
3871
  get selectedIds() { return [...selectedIds]; },
3830
3872
  get selectionKind() { return selectionKind(); },
@@ -35,6 +35,12 @@ const PREVIEW_3D_MAX_RENDER_SIZE = 720;
35
35
  // getAnimations() 一覧のキャッシュ寿命(ms。app.js と同値)。詳細は tick() の注記。
36
36
  const ANIMATIONS_CACHE_MS = 250;
37
37
 
38
+ // 再生中の CSS アニメは走らせたままにし、タイムラインとのずれがこの幅を超えたときだけ
39
+ // currentTime を書き戻す(ms。出力 30fps の 1.5 フレーム相当)。詳細は syncAnimation() の注記。
40
+ const PLAYBACK_DRIFT_MS = 50;
41
+ // 再生速度の推定に使う最短の観測窓(ms)。これより短い区間の Δ時刻/Δ壁時計 はフレーム量子化で暴れる。
42
+ const PLAYBACK_RATE_WINDOW_MS = 200;
43
+
38
44
  // maxRenderSize の正規化: 未指定 → 既定 720 / null・0・false → 無効(等倍) /
39
45
  // 正の有限数 → その値。それ以外の不正値(負数・NaN・文字列)は既定へ戻す。
40
46
  function resolveMaxRenderSize(value) {
@@ -130,6 +136,82 @@ function createOverlayRuntime(options = {}) {
130
136
  return true;
131
137
  }
132
138
 
139
+ // CSS アニメ 1 本をタイムラインのローカル時刻へ合わせる。
140
+ //
141
+ // 停止・スクラブ中は従来どおり pause + currentTime で毎 tick その時刻の姿勢に固定する
142
+ // (シーク後の絵の決定性はここで担保する)。
143
+ //
144
+ // 再生中は毎フレームのシークをやめ、Animation を走らせたまま(play)にする。毎フレーム
145
+ // currentTime を書くとアニメがメインスレッド駆動になり、拡大縮小を含む層が毎フレーム
146
+ // ラスタし直される(実測: CSS アニメ 339 本の合成断片で GPU ラスタ ~115 回/フレーム・
147
+ // 60Hz 表示で 30〜34fps。走らせたままにすると ~58fps・ラスタ 1/4.5)。ずれが
148
+ // PLAYBACK_DRIFT_MS を超えたとき(動画側の停滞・速度変更の直後など)だけ書き戻す。
149
+ //
150
+ // 有限アニメの終端以降は play() しない(finished な Animation への play() は先頭へ
151
+ // 巻き戻る)。pause して fill の姿勢で止め、以後は同じ値を書き直さない。
152
+ //
153
+ // 走っていた Animation の pause() / play() は次のフレームまで保留(pending)され、保留が
154
+ // 解けたときの時刻で currentTime が上書きされることがある(実測: 巻き戻し再生の直後に
155
+ // 止めると、まれに 1 フレーム 16.6ms 進んだ姿勢で止まる)。止めた時刻を pausedTargets に
156
+ // 記録し、保留が解けた(ready)後にもう一度書き戻す。再生へ戻ったら記録を消す。
157
+ const pausedTargets = new WeakMap();
158
+ function holdPausedTime(animation, localTimeMs) {
159
+ pausedTargets.set(animation, localTimeMs);
160
+ if (!animation.pending || !animation.ready?.then) return;
161
+ animation.ready.then(() => {
162
+ if (pausedTargets.get(animation) !== localTimeMs || animation.playState !== "paused") return;
163
+ if (Number(animation.currentTime) !== localTimeMs) animation.currentTime = localTimeMs;
164
+ }, () => {});
165
+ }
166
+
167
+ function syncAnimation(animation, localTimeMs, playing, playbackRate) {
168
+ const endTime = Number(animation.effect?.getComputedTiming?.().endTime);
169
+ const current = Number(animation.currentTime);
170
+ if (playing && !(localTimeMs >= endTime)) {
171
+ pausedTargets.delete(animation);
172
+ if (animation.playState !== "running") {
173
+ animation.currentTime = localTimeMs;
174
+ animation.playbackRate = playbackRate;
175
+ animation.play();
176
+ return;
177
+ }
178
+ if (animation.playbackRate !== playbackRate) animation.playbackRate = playbackRate;
179
+ if (!Number.isFinite(current) || Math.abs(current - localTimeMs) > PLAYBACK_DRIFT_MS) {
180
+ animation.currentTime = localTimeMs;
181
+ }
182
+ return;
183
+ }
184
+ if (playing && animation.playState === "paused" && current >= endTime) return;
185
+ if (animation.playState !== "paused") animation.pause();
186
+ if (current !== localTimeMs) animation.currentTime = localTimeMs;
187
+ holdPausedTime(animation, localTimeMs);
188
+ }
189
+
190
+ // 再生速度(タイムライン秒 / 壁時計秒)の推定。ホストは tick(t, playing) しか渡さないので、
191
+ // 再生中の連続 tick から測る。停止・逆行・大きな飛び(シーク)で観測をやり直す。
192
+ let playbackAnchor = null;
193
+ let playbackRate = 1;
194
+ function observePlaybackRate(timelineTime, playing, nowMs) {
195
+ if (!playing) {
196
+ playbackAnchor = null;
197
+ return playbackRate;
198
+ }
199
+ const expected = playbackAnchor
200
+ ? playbackAnchor.time + ((nowMs - playbackAnchor.wall) / 1000) * playbackRate
201
+ : timelineTime;
202
+ if (!playbackAnchor || timelineTime < playbackAnchor.time || Math.abs(timelineTime - expected) > 1) {
203
+ playbackAnchor = { time: timelineTime, wall: nowMs };
204
+ return playbackRate;
205
+ }
206
+ const elapsed = nowMs - playbackAnchor.wall;
207
+ if (elapsed >= PLAYBACK_RATE_WINDOW_MS) {
208
+ const measured = Math.round(((timelineTime - playbackAnchor.time) * 1000 / elapsed) * 20) / 20;
209
+ if (measured > 0 && Number.isFinite(measured)) playbackRate = measured;
210
+ playbackAnchor = { time: timelineTime, wall: nowMs };
211
+ }
212
+ return playbackRate;
213
+ }
214
+
133
215
  function unmount() {
134
216
  for (const overlay of mountedOverlays) {
135
217
  for (const runtime of renderingRuntimes(overlay.container)) runtime.dispose(overlay.container);
@@ -324,6 +406,7 @@ function createOverlayRuntime(options = {}) {
324
406
  function tick(t, playing) {
325
407
  const timelineTime = finiteNumber(t, 0);
326
408
  if (premount && !premountConfigured) applyPremountConfiguration();
409
+ const rate = observePlaybackRate(timelineTime, Boolean(playing), performance.now());
327
410
 
328
411
  for (const overlay of mountedOverlays) {
329
412
  const visible =
@@ -386,7 +469,7 @@ function createOverlayRuntime(options = {}) {
386
469
  // 総数」にほぼ比例する(上の注記)。断片のアニメは `[data-akari-active]` ゲートで宣言する
387
470
  // 規約なので、可視の間は顔ぶれが変わらない。毎 tick 引き直さず、可視化フリップ直後の
388
471
  // tick と 250ms ごとだけ引き直す(遅れて生える animation も拾える。app.js と同じ)。
389
- // Animation オブジェクトはライブなので、キャッシュ済みでも pause / currentTime の書き込みと
472
+ // Animation オブジェクトはライブなので、キャッシュ済みでも syncAnimation() の書き込みと
390
473
  // 下の entryAnimationsSettled() の読み取りは現在値で動く。
391
474
  //
392
475
  // 3D 断片も必ずここを通す。three のシーンは three 側が時刻を持つ(mixer.setTime)が、
@@ -405,10 +488,7 @@ function createOverlayRuntime(options = {}) {
405
488
  overlay.animationsAt = nowMs;
406
489
  }
407
490
  const animations = overlay.animations;
408
- for (const animation of animations) {
409
- animation.pause();
410
- animation.currentTime = localTimeMs;
411
- }
491
+ for (const animation of animations) syncAnimation(animation, localTimeMs, Boolean(playing), rate);
412
492
  for (const runtime of renderingRuntimes(overlay.container)) {
413
493
  // playing はランタイム側の動画テクスチャの同期方式(再生中は <video> を走らせ、停止・スクラブ中はシーク)に使う
414
494
  runtime.render(overlay.container, localTimeMs / 1000, { syncVideos: true, maxRenderSize, playing: Boolean(playing) });