akari-video 0.1.60 → 0.1.62

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.60",
3
+ "version": "0.1.62",
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.60",
3
+ "version": "0.1.62",
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": [
@@ -11,3 +11,13 @@ export declare function isCutAudioAudible(cut: {
11
11
  }, track?: {
12
12
  muted?: unknown;
13
13
  }): boolean;
14
+ /** Video layers own embedded speech even when their pixels are hidden or transformed. */
15
+ export declare function isLayerAudioAudible(layer: {
16
+ kind?: unknown;
17
+ src?: unknown;
18
+ isImage?: unknown;
19
+ audio?: unknown;
20
+ mute?: unknown;
21
+ }, track?: {
22
+ muted?: unknown;
23
+ }): boolean;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isAudioItemAudible = isAudioItemAudible;
4
4
  exports.isCutAudioAudible = isCutAudioAudible;
5
+ exports.isLayerAudioAudible = isLayerAudioAudible;
5
6
  /** Audibility belongs to the owning track and item, independently of its speech role. */
6
7
  function isAudioItemAudible(track, item) {
7
8
  return track?.muted !== true && item?.mute !== true;
@@ -10,3 +11,10 @@ function isAudioItemAudible(track, item) {
10
11
  function isCutAudioAudible(cut, track) {
11
12
  return cut.audio !== false && isAudioItemAudible(track, cut);
12
13
  }
14
+ /** Video layers own embedded speech even when their pixels are hidden or transformed. */
15
+ function isLayerAudioAudible(layer, track) {
16
+ return layer.kind === 'video' && layer.isImage !== true
17
+ && typeof layer.src === 'string' && layer.src.length > 0
18
+ && !/\.(?:png|jpe?g|webp|bmp|gif|svg)(?:[?#].*)?$/iu.test(layer.src)
19
+ && isCutAudioAudible(layer, track);
20
+ }
@@ -1,5 +1,5 @@
1
1
  import { DuckInterval } from './ducking';
2
- import type { EditCut } from './edit-store';
2
+ import { type EditCut } from './edit-store';
3
3
  export type WebAudioScheduleKind = 'bgm' | 'sfx' | 'narration' | 'speech';
4
4
  export interface WebAudioDecodedItem {
5
5
  id?: string;
@@ -138,4 +138,27 @@ export declare function buildWebAudioSchedule(input: WebAudioScheduleInput): Web
138
138
  */
139
139
  export declare function projectSpeechDeclarations(cuts: readonly WebAudioSpeechCut[], options: {
140
140
  fps: number;
141
+ layers?: readonly WebAudioSpeechLayer[];
141
142
  }): WebAudioSpeechDeclaration[];
143
+ /** Layer timing is output time; only moving source regions supply speech. */
144
+ export interface WebAudioSpeechLayer {
145
+ id?: string;
146
+ kind?: string;
147
+ src?: string;
148
+ isImage?: boolean;
149
+ t: number;
150
+ duration: number;
151
+ in?: number;
152
+ speed?: number;
153
+ track?: number;
154
+ audio?: boolean;
155
+ mute?: unknown;
156
+ gain_db?: unknown;
157
+ freeze?: WebAudioSpeechCut['freeze'];
158
+ transition_out?: WebAudioSpeechCut['transition_out'];
159
+ }
160
+ export declare function projectLayerSpeechDeclarations(layers: readonly WebAudioSpeechLayer[], options: {
161
+ fps: number;
162
+ }): Array<WebAudioSpeechDeclaration & {
163
+ scope: 'layers';
164
+ }>;
@@ -2,10 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildWebAudioSchedule = buildWebAudioSchedule;
4
4
  exports.projectSpeechDeclarations = projectSpeechDeclarations;
5
+ exports.projectLayerSpeechDeclarations = projectLayerSpeechDeclarations;
5
6
  const audio_ownership_1 = require("./audio-ownership");
6
7
  const ducking_1 = require("./ducking");
7
8
  const envelope_1 = require("./envelope");
8
9
  const timeline_map_1 = require("./timeline-map");
10
+ const edit_store_1 = require("./edit-store");
9
11
  /**
10
12
  * 解決済みタイムライン尺・正規化済み audio 宣言・デコード実尺を、Web Audio がそのまま
11
13
  * 消費できる予定表へ落とす。fetch/decode/時計は扱わないため、実時間と OfflineAudioContext
@@ -305,21 +307,45 @@ function projectSpeechDeclarations(cuts, options) {
305
307
  return { ...cut, out: cut.out + holdSec * speed };
306
308
  });
307
309
  const map = (0, timeline_map_1.buildTimelineMap)(virtualCuts, { fps });
310
+ // Keep every clip's window, including clips absent from the visual winner map.
311
+ // Match buildTimelineMap's usable-cut filtering before resolving track cursors.
312
+ const usable = virtualCuts.map((cut, index) => ({ cut, index })).filter(({ cut }) => Number.isFinite(cut.in) && Number.isFinite(cut.out) && cut.in < cut.out);
313
+ const clipWindows = new Map((0, edit_store_1.computeCutTrackSegments)(usable.map(entry => entry.cut)).map(segment => [
314
+ usable[segment.index].index,
315
+ { start: segment.at, end: segment.end, cutTimelineStart: segment.at }
316
+ ]));
317
+ for (const window of map.transitionWindows) {
318
+ for (const participant of [window.outgoing, window.incoming]) {
319
+ const clip = clipWindows.get(participant.cutIndex);
320
+ const cut = normalizedCuts[participant.cutIndex];
321
+ if (!clip || !cut || typeof participant.in !== 'number')
322
+ continue;
323
+ const speed = finitePositive(cut.speed) ? cut.speed : 1;
324
+ // Transition slices retain the source mapping after hidden-handle adjustment,
325
+ // even when another track completely covers both participants.
326
+ clip.cutTimelineStart = participant.outStart - (participant.in - cut.in) / speed;
327
+ }
328
+ const outgoing = clipWindows.get(window.outgoing.cutIndex);
329
+ const incoming = clipWindows.get(window.incoming.cutIndex);
330
+ if (outgoing)
331
+ outgoing.end = Math.max(outgoing.end, window.end);
332
+ // scheduleSpeech adds this incoming overlap back via crossfadeInSec. Keep the
333
+ // existing declaration boundary so the transition is not applied twice.
334
+ if (incoming)
335
+ incoming.start = Math.max(incoming.start, window.end);
336
+ }
308
337
  const declarations = [];
309
- for (const segment of map.segments) {
310
- if (segment.kind !== 'src' || segment.cutIndex === null)
311
- continue;
312
- const cut = normalizedCuts[segment.cutIndex];
338
+ for (const [cutIndex, clip] of clipWindows) {
339
+ const cut = normalizedCuts[cutIndex];
313
340
  if (!cut || typeof cut.src !== 'string' || !cut.src)
314
341
  continue;
315
342
  if (!(0, audio_ownership_1.isCutAudioAudible)(cut))
316
343
  continue;
317
344
  const speed = finitePositive(cut.speed) ? cut.speed : 1;
318
- const segmentIn = typeof segment.in === 'number' ? segment.in : cut.in;
319
- const cutTimelineStart = segment.outStart - (segmentIn - cut.in) / speed;
345
+ const cutTimelineStart = clip.cutTimelineStart;
320
346
  const baseDurationSec = Math.max(0, cut.out - cut.in) / speed;
321
347
  const gainDb = speechGainDb(cut);
322
- const baseId = typeof cut.id === 'string' && cut.id ? cut.id : `cut-${segment.cutIndex}`;
348
+ const baseId = speechBaseId(cut, cutIndex);
323
349
  const holdSec = freezeDuration(cut.freeze);
324
350
  if (!(holdSec > 0)) {
325
351
  appendSpeechIntersection(declarations, {
@@ -327,8 +353,8 @@ function projectSpeechDeclarations(cuts, options) {
327
353
  sourceIn: cut.in,
328
354
  outputStart: cutTimelineStart,
329
355
  outputEnd: cutTimelineStart + baseDurationSec,
330
- segmentStart: segment.outStart,
331
- segmentEnd: segment.outEnd,
356
+ clipStart: clip.start,
357
+ clipEnd: clip.end,
332
358
  track: cut.track
333
359
  });
334
360
  continue;
@@ -340,8 +366,8 @@ function projectSpeechDeclarations(cuts, options) {
340
366
  sourceIn: cut.in,
341
367
  outputStart: cutTimelineStart,
342
368
  outputEnd: cutTimelineStart + freezeAtSec,
343
- segmentStart: segment.outStart,
344
- segmentEnd: segment.outEnd,
369
+ clipStart: clip.start,
370
+ clipEnd: clip.end,
345
371
  track: cut.track
346
372
  });
347
373
  appendSpeechIntersection(declarations, {
@@ -349,8 +375,8 @@ function projectSpeechDeclarations(cuts, options) {
349
375
  sourceIn: freezeSourceIn,
350
376
  outputStart: cutTimelineStart + freezeAtSec + holdSec,
351
377
  outputEnd: cutTimelineStart + baseDurationSec + holdSec,
352
- segmentStart: segment.outStart,
353
- segmentEnd: segment.outEnd,
378
+ clipStart: clip.start,
379
+ clipEnd: clip.end,
354
380
  track: cut.track
355
381
  });
356
382
  }
@@ -375,14 +401,32 @@ function projectSpeechDeclarations(cuts, options) {
375
401
  incoming.crossfadeInSec = Math.max(incoming.crossfadeInSec ?? 0, window.duration);
376
402
  }
377
403
  }
404
+ if (options.layers?.length)
405
+ declarations.push(...projectLayerSpeechDeclarations(options.layers, { fps }));
378
406
  return declarations;
379
407
  }
408
+ function projectLayerSpeechDeclarations(layers, options) {
409
+ const cuts = layers.map((layer, index) => {
410
+ const speed = finitePositive(layer.speed) ? layer.speed : 1;
411
+ const sourceIn = finiteNonNegative(layer.in) ? layer.in : 0;
412
+ return {
413
+ ...layer,
414
+ id: `layer-${layer.id || index}`,
415
+ in: sourceIn,
416
+ out: sourceIn + Math.max(0, layer.duration - freezeDuration(layer.freeze)) * speed,
417
+ at: layer.t,
418
+ speed,
419
+ audio: (0, audio_ownership_1.isLayerAudioAudible)(layer) ? undefined : false,
420
+ };
421
+ });
422
+ return projectSpeechDeclarations(cuts, options).map(item => ({ ...item, scope: 'layers' }));
423
+ }
380
424
  function speechBaseId(cut, index) {
381
425
  return cut && typeof cut.id === 'string' && cut.id ? cut.id : `cut-${index}`;
382
426
  }
383
427
  function appendSpeechIntersection(declarations, input) {
384
- const atSec = Math.max(input.outputStart, input.segmentStart);
385
- const endSec = Math.min(input.outputEnd, input.segmentEnd);
428
+ const atSec = Math.max(input.outputStart, input.clipStart);
429
+ const endSec = Math.min(input.outputEnd, input.clipEnd);
386
430
  if (!(endSec > atSec))
387
431
  return;
388
432
  const inSec = input.sourceIn + (atSec - input.outputStart) * input.speed;
@@ -1130,7 +1130,8 @@ function projectLegacyEdit(internal) {
1130
1130
  audioBgm = value;
1131
1131
  break;
1132
1132
  case 'layers':
1133
- layers.push({ index: item.legacy.index, value: value });
1133
+ layers.push({ index: item.legacy.index, value: (track.lane === 'visual' && track.muted === true
1134
+ ? { ...value, mute: true } : value) });
1134
1135
  break;
1135
1136
  default:
1136
1137
  cuts.push({
@@ -48,11 +48,13 @@ var AkariEditKernel = (() => {
48
48
  findActiveResolvedCaption: () => findActiveResolvedCaption,
49
49
  isAudioItemAudible: () => isAudioItemAudible,
50
50
  isCutAudioAudible: () => isCutAudioAudible,
51
+ isLayerAudioAudible: () => isLayerAudioAudible,
51
52
  isTransitionType: () => isTransitionType,
52
53
  isWithinDuckInterval: () => isWithinDuckInterval,
53
54
  mergePresetTextStyle: () => mergePresetTextStyle,
54
55
  normalizeCaptionClock: () => normalizeCaptionClock,
55
56
  outputToSource: () => outputToSource,
57
+ projectLayerSpeechDeclarations: () => projectLayerSpeechDeclarations,
56
58
  projectSpeechDeclarations: () => projectSpeechDeclarations,
57
59
  projectSpeechKeyIntervals: () => projectSpeechKeyIntervals,
58
60
  resolveCaptionStylePreset: () => resolveCaptionStylePreset,
@@ -1154,6 +1156,9 @@ var AkariEditKernel = (() => {
1154
1156
  function isCutAudioAudible(cut, track) {
1155
1157
  return cut.audio !== false && isAudioItemAudible(track, cut);
1156
1158
  }
1159
+ function isLayerAudioAudible(layer, track) {
1160
+ return layer.kind === "video" && layer.isImage !== true && typeof layer.src === "string" && layer.src.length > 0 && !/\.(?:png|jpe?g|webp|bmp|gif|svg)(?:[?#].*)?$/iu.test(layer.src) && isCutAudioAudible(layer, track);
1161
+ }
1157
1162
 
1158
1163
  // src/audio-schedule.ts
1159
1164
  function buildWebAudioSchedule(input) {
@@ -1452,18 +1457,34 @@ var AkariEditKernel = (() => {
1452
1457
  return { ...cut, out: cut.out + holdSec * speed };
1453
1458
  });
1454
1459
  const map = buildTimelineMap(virtualCuts, { fps });
1460
+ const usable = virtualCuts.map((cut, index) => ({ cut, index })).filter(({ cut }) => Number.isFinite(cut.in) && Number.isFinite(cut.out) && cut.in < cut.out);
1461
+ const clipWindows = new Map(computeCutTrackSegments(usable.map((entry) => entry.cut)).map((segment) => [
1462
+ usable[segment.index].index,
1463
+ { start: segment.at, end: segment.end, cutTimelineStart: segment.at }
1464
+ ]));
1465
+ for (const window of map.transitionWindows) {
1466
+ for (const participant of [window.outgoing, window.incoming]) {
1467
+ const clip = clipWindows.get(participant.cutIndex);
1468
+ const cut = normalizedCuts[participant.cutIndex];
1469
+ if (!clip || !cut || typeof participant.in !== "number") continue;
1470
+ const speed = finitePositive(cut.speed) ? cut.speed : 1;
1471
+ clip.cutTimelineStart = participant.outStart - (participant.in - cut.in) / speed;
1472
+ }
1473
+ const outgoing = clipWindows.get(window.outgoing.cutIndex);
1474
+ const incoming = clipWindows.get(window.incoming.cutIndex);
1475
+ if (outgoing) outgoing.end = Math.max(outgoing.end, window.end);
1476
+ if (incoming) incoming.start = Math.max(incoming.start, window.end);
1477
+ }
1455
1478
  const declarations = [];
1456
- for (const segment of map.segments) {
1457
- if (segment.kind !== "src" || segment.cutIndex === null) continue;
1458
- const cut = normalizedCuts[segment.cutIndex];
1479
+ for (const [cutIndex, clip] of clipWindows) {
1480
+ const cut = normalizedCuts[cutIndex];
1459
1481
  if (!cut || typeof cut.src !== "string" || !cut.src) continue;
1460
1482
  if (!isCutAudioAudible(cut)) continue;
1461
1483
  const speed = finitePositive(cut.speed) ? cut.speed : 1;
1462
- const segmentIn = typeof segment.in === "number" ? segment.in : cut.in;
1463
- const cutTimelineStart = segment.outStart - (segmentIn - cut.in) / speed;
1484
+ const cutTimelineStart = clip.cutTimelineStart;
1464
1485
  const baseDurationSec = Math.max(0, cut.out - cut.in) / speed;
1465
1486
  const gainDb = speechGainDb(cut);
1466
- const baseId = typeof cut.id === "string" && cut.id ? cut.id : `cut-${segment.cutIndex}`;
1487
+ const baseId = speechBaseId(cut, cutIndex);
1467
1488
  const holdSec = freezeDuration(cut.freeze);
1468
1489
  if (!(holdSec > 0)) {
1469
1490
  appendSpeechIntersection(declarations, {
@@ -1474,8 +1495,8 @@ var AkariEditKernel = (() => {
1474
1495
  sourceIn: cut.in,
1475
1496
  outputStart: cutTimelineStart,
1476
1497
  outputEnd: cutTimelineStart + baseDurationSec,
1477
- segmentStart: segment.outStart,
1478
- segmentEnd: segment.outEnd,
1498
+ clipStart: clip.start,
1499
+ clipEnd: clip.end,
1479
1500
  track: cut.track
1480
1501
  });
1481
1502
  continue;
@@ -1490,8 +1511,8 @@ var AkariEditKernel = (() => {
1490
1511
  sourceIn: cut.in,
1491
1512
  outputStart: cutTimelineStart,
1492
1513
  outputEnd: cutTimelineStart + freezeAtSec,
1493
- segmentStart: segment.outStart,
1494
- segmentEnd: segment.outEnd,
1514
+ clipStart: clip.start,
1515
+ clipEnd: clip.end,
1495
1516
  track: cut.track
1496
1517
  });
1497
1518
  appendSpeechIntersection(declarations, {
@@ -1502,8 +1523,8 @@ var AkariEditKernel = (() => {
1502
1523
  sourceIn: freezeSourceIn,
1503
1524
  outputStart: cutTimelineStart + freezeAtSec + holdSec,
1504
1525
  outputEnd: cutTimelineStart + baseDurationSec + holdSec,
1505
- segmentStart: segment.outStart,
1506
- segmentEnd: segment.outEnd,
1526
+ clipStart: clip.start,
1527
+ clipEnd: clip.end,
1507
1528
  track: cut.track
1508
1529
  });
1509
1530
  }
@@ -1524,14 +1545,31 @@ var AkariEditKernel = (() => {
1524
1545
  incoming.crossfadeInSec = Math.max(incoming.crossfadeInSec ?? 0, window.duration);
1525
1546
  }
1526
1547
  }
1548
+ if (options.layers?.length) declarations.push(...projectLayerSpeechDeclarations(options.layers, { fps }));
1527
1549
  return declarations;
1528
1550
  }
1551
+ function projectLayerSpeechDeclarations(layers, options) {
1552
+ const cuts = layers.map((layer, index) => {
1553
+ const speed = finitePositive(layer.speed) ? layer.speed : 1;
1554
+ const sourceIn = finiteNonNegative(layer.in) ? layer.in : 0;
1555
+ return {
1556
+ ...layer,
1557
+ id: `layer-${layer.id || index}`,
1558
+ in: sourceIn,
1559
+ out: sourceIn + Math.max(0, layer.duration - freezeDuration(layer.freeze)) * speed,
1560
+ at: layer.t,
1561
+ speed,
1562
+ audio: isLayerAudioAudible(layer) ? void 0 : false
1563
+ };
1564
+ });
1565
+ return projectSpeechDeclarations(cuts, options).map((item) => ({ ...item, scope: "layers" }));
1566
+ }
1529
1567
  function speechBaseId(cut, index) {
1530
1568
  return cut && typeof cut.id === "string" && cut.id ? cut.id : `cut-${index}`;
1531
1569
  }
1532
1570
  function appendSpeechIntersection(declarations, input) {
1533
- const atSec = Math.max(input.outputStart, input.segmentStart);
1534
- const endSec = Math.min(input.outputEnd, input.segmentEnd);
1571
+ const atSec = Math.max(input.outputStart, input.clipStart);
1572
+ const endSec = Math.min(input.outputEnd, input.clipEnd);
1535
1573
  if (!(endSec > atSec)) return;
1536
1574
  const inSec = input.sourceIn + (atSec - input.outputStart) * input.speed;
1537
1575
  const outSec = inSec + (endSec - atSec) * input.speed;
@@ -3489,7 +3527,7 @@ var AkariEditKernel = (() => {
3489
3527
  audioBgm = value;
3490
3528
  break;
3491
3529
  case "layers":
3492
- layers.push({ index: item.legacy.index, value });
3530
+ layers.push({ index: item.legacy.index, value: track.lane === "visual" && track.muted === true ? { ...value, mute: true } : value });
3493
3531
  break;
3494
3532
  default:
3495
3533
  cuts.push({
@@ -1368,6 +1368,8 @@
1368
1368
  "duration": { "$ref": "#/$defs/positiveNumber" },
1369
1369
  "kind": { "enum": ["baked", "video", "filter"] },
1370
1370
  "src": { "type": "string", "minLength": 1, "pattern": "\\S" },
1371
+ "audio": { "type": "boolean", "default": true, "description": "Play embedded audio for video layers; ignored for other kinds." },
1372
+ "gain_db": { "type": "number", "minimum": -60, "maximum": 12, "default": 0 },
1371
1373
  "filter": { "$ref": "#/$defs/layerFilter" },
1372
1374
  "preset": { "type": "string" },
1373
1375
  "params": { "type": "object" },
@@ -0,0 +1,25 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import Ajv2020 from 'ajv/dist/2020.js';
5
+ const schema = JSON.parse(readFileSync(new URL('../edit.schema.json', import.meta.url), 'utf8'));
6
+ const layer = () => ({ id: 'pip', kind: 'video', src: 'pip.mov', t: 1, duration: 3 });
7
+ const compile = useDefaults => new Ajv2020({ strict: false, useDefaults }).compile({
8
+ $defs: schema.$defs, $ref: '#/$defs/layerItem',
9
+ });
10
+ test('legacy layer audio omission stays valid and defaults to audible at 0 dB', () => {
11
+ const value = layer(), before = JSON.stringify(value);
12
+ assert.equal(compile(false)(value), true);
13
+ assert.equal(JSON.stringify(value), before);
14
+ assert.equal(compile(true)(value), true);
15
+ assert.equal(value.audio, true);
16
+ assert.equal(value.gain_db, 0);
17
+ });
18
+ test('layer audio accepts booleans and gain endpoints, rejecting wrong types and ranges', () => {
19
+ const validate = compile(false);
20
+ for (const audio of [true, false]) for (const gain_db of [-60, 0, 12]) {
21
+ assert.equal(validate({ ...layer(), audio, gain_db }), true);
22
+ }
23
+ for (const audio of [0, null, 'false']) assert.equal(validate({ ...layer(), audio }), false);
24
+ for (const gain_db of [-60.1, 12.1, null, '0']) assert.equal(validate({ ...layer(), gain_db }), false);
25
+ });