A clip of a video could not be laid on a composition track at all, and neither could a clip of a sound, although #418 and the wiki said so. The only place a drag starts is the asset panel, and it listed assets, not clips. The texture label of an item playing a video clip was dead too, because the reference lookup did not know video clips. - The asset panel gives a sound or video with clips a chevron of its own. It unfolds one row per clip and starts closed. - A clip row drags like an asset. One click shows the clip in the inspector without leaving the tab (`EditorState.glance`). A double click opens its sound or video with the clip picked. - A video clip dropped on a composition track becomes an item as long as the clip. It wears the sprite of its whole video, so a composition keeps one sprite per video, and the clip is a texture override on the object. Sprite, item and override are one undo step (`droppedPicture`). - A sound clip becomes an audio item as long as the clip. The song tab takes sound clips on the same way, through `findSound`. - `referencedTarget` lets a reference label link to the clip itself. One click pings the clip row, a double click opens the asset with the clip picked. The Plays row of an audio item reads "Sound / Clip". - `clipOwner` in `sound.ts` finds the sound or video a clip id belongs to. `activateAssetOrClip` uses it for the double clicks. Found on the way: the Blip of the e2e seed is only 0.25 s long, so a clip of 0.2 to 0.7 s on it is cut to one frame. 69s takes the Drone (0.75 s) instead. Tests: - tsc, eslint, Prettier clean - Unit on stein2 (composition, references, sound, video, editor): 94 files, 1238 tests green - e2e 69 on melektaus-atx: ALL PASSED, 25 checks, 2 of them new (69s) - Gate on melektaus-atx with the fix for 68k on top: CHECK EXIT 0, 313 files, 4841 tests - Counter checks, all red: no clip rows in e2e 69 (no drag starts), no clip override in the unit test, the link aimed at the asset instead of the clip - Not covered by its own e2e check: sound clips on the song tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
838 lines
38 KiB
JavaScript
838 lines
38 KiB
JavaScript
/**
|
||
* The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long
|
||
* and picked, so the inspector shows its TIMING; a second one dropped onto the first lands there,
|
||
* over it. The end of the first, covered by the second, lights up under the pointer and is dragged
|
||
* past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture.
|
||
* The hierarchy lists what is alive at the playhead, Delete takes an item with its object, and Play
|
||
* runs the playhead to the end. The video of an item about to begin is opened ahead, so the cut to
|
||
* it shows its picture at once, and Play waits for a video whose player is still loading. A copied
|
||
* item is pasted under the pointer or at the playhead. A video dragged in wears a sprite the
|
||
* composition keeps to itself. An item dragged in the picture follows the pointer while the button
|
||
* is down. A band picks items, with Alt clips, and copied clips are pasted where they fit. A sound
|
||
* dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a
|
||
* fade its bus rises along. A video item's sprite reads as driven by the composition and goes into
|
||
* its bus, which has a node in the master routing; after a hitch the sound is started again where
|
||
* the playhead is. Clips of a sound and a video have rows in the Assets panel, are dragged onto a
|
||
* track like an asset, and the Texture label links the clip.
|
||
*
|
||
* Everything it adds carries a `ct69` id and goes again at the end.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { uploadVideo } from '../lib/projects.mjs';
|
||
|
||
const CUT = 'ct69-cut';
|
||
const BOX = 'ct69-box';
|
||
const SLIDE = 'ct69-slide';
|
||
const VIDEO = 'ct69-video';
|
||
const SCREEN = 'ct69-screen';
|
||
|
||
export async function compositionTab(t) {
|
||
const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t;
|
||
/** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */
|
||
const dragAsset = async (assetId, to) => {
|
||
// A press on the row of the drag before, this soon, would read as a double-click that opens it.
|
||
await sleep(450);
|
||
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
|
||
await mouse('mouseMoved', from.x, from.y);
|
||
await mouse('mousePressed', from.x, from.y);
|
||
for (let i = 1; i <= 8; i++) {
|
||
await mouse(
|
||
'mouseMoved',
|
||
from.x + ((to.x - from.x) * i) / 8,
|
||
from.y + ((to.y - from.y) * i) / 8,
|
||
'left',
|
||
{
|
||
buttons: 1,
|
||
},
|
||
);
|
||
await sleep(16);
|
||
}
|
||
const drop = await json('__gm.composition.drop()');
|
||
await mouse('mouseReleased', to.x, to.y);
|
||
await sleep(200);
|
||
return drop;
|
||
};
|
||
const model = () =>
|
||
json(`(() => {
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips };
|
||
})()`);
|
||
|
||
await press('Escape');
|
||
await showTab('scene');
|
||
// Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push(
|
||
{ id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [],
|
||
tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] },
|
||
{ id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } },
|
||
{ id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}',
|
||
tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] },
|
||
);`);
|
||
await evaluate(`__gm.state.openAsset('${CUT}')`);
|
||
await sleep(300);
|
||
const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`);
|
||
check(
|
||
'opening a composition brings up its tab',
|
||
opened.tab === 'composition' && opened.current === CUT,
|
||
JSON.stringify(opened),
|
||
);
|
||
|
||
// 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked.
|
||
const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
|
||
let m = await model();
|
||
const a = m.items[0];
|
||
const picked = await json('__gm.state.selection');
|
||
check(
|
||
'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked',
|
||
first?.what === 'item' &&
|
||
first.rejection === '' &&
|
||
m.items.length === 1 &&
|
||
a.start === 5 &&
|
||
a.length === 30 &&
|
||
a.fitMedia === true &&
|
||
JSON.stringify(m.objects) === JSON.stringify([a.id]) &&
|
||
picked?.kind === 'object' &&
|
||
picked.holderId === CUT &&
|
||
picked.objectId === a.id,
|
||
JSON.stringify({ first, m, picked }),
|
||
);
|
||
const timing = await json(`__gm.section('TIMING')`);
|
||
check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing));
|
||
|
||
// 69b. A second one let go over the first lands where it is let go, over the first.
|
||
await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`));
|
||
m = await model();
|
||
const b = m.items.find((i) => i.id !== a.id);
|
||
check(
|
||
'one dropped onto an item lands where it is let go, over it',
|
||
m.items.length === 2 && b?.start === 20 && b.length === 30,
|
||
JSON.stringify(m.items),
|
||
);
|
||
|
||
// 69c. The first item's end, under the second, lights up with a resize cursor and is dragged past it.
|
||
const box = await json(`__gm.composition.box('${a.id}')`);
|
||
const end = { x: box.x + box.w - 2, y: box.y + box.h / 2 };
|
||
await mouse('mouseMoved', end.x - 20, end.y);
|
||
await mouse('mouseMoved', end.x, end.y);
|
||
await sleep(80);
|
||
const hovered = await json('__gm.composition.view()');
|
||
const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`);
|
||
await drag(end.x, end.y, far.x, end.y);
|
||
await sleep(150);
|
||
m = await model();
|
||
const resized = m.items.find((i) => i.id === a.id);
|
||
check(
|
||
'the covered end of an item lights up with a resize cursor, and is dragged past the item over it',
|
||
hovered.hover?.id === a.id &&
|
||
hovered.hover.part === 'end' &&
|
||
hovered.cursor === 'ew-resize' &&
|
||
resized?.length === 55 &&
|
||
resized.fitMedia === undefined,
|
||
JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, resized }),
|
||
);
|
||
|
||
// 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture.
|
||
const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
|
||
m = await model();
|
||
const x = async (frame) => {
|
||
await evaluate(`__gm.composition.scrub(${frame})`);
|
||
await sleep(60);
|
||
return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null;
|
||
};
|
||
const xs = [await x(5), await x(8), await x(20)];
|
||
check(
|
||
'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture',
|
||
clipDrop?.what === 'clip' &&
|
||
m.clips.length === 1 &&
|
||
m.clips[0].start === 5 &&
|
||
m.clips[0].animation === SLIDE &&
|
||
xs[0] === 0 &&
|
||
xs[1] === 60 &&
|
||
xs[2] === 100,
|
||
JSON.stringify({ clipDrop, clips: m.clips, xs }),
|
||
);
|
||
|
||
// 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows.
|
||
const rowsAt = async (frame) => {
|
||
await evaluate(`__gm.composition.scrub(${frame})`);
|
||
await sleep(80);
|
||
const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`);
|
||
return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id);
|
||
};
|
||
const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)];
|
||
check(
|
||
'the hierarchy lists the items alive at the playhead',
|
||
rows[0].length === 0 &&
|
||
JSON.stringify(rows[1]) === JSON.stringify([a.id]) &&
|
||
JSON.stringify(rows[2]) === JSON.stringify([a.id, b.id]),
|
||
JSON.stringify(rows),
|
||
);
|
||
|
||
// 69f. Delete takes the picked item with its object; undo brings both back.
|
||
await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`);
|
||
await press('Delete');
|
||
await sleep(150);
|
||
const gone = await model();
|
||
await press('z', { ctrl: true });
|
||
await sleep(150);
|
||
const back = await model();
|
||
check(
|
||
'Delete takes an item with its object, undo brings both back',
|
||
!gone.items.some((i) => i.id === b.id) &&
|
||
!gone.objects.includes(b.id) &&
|
||
back.items.some((i) => i.id === b.id) &&
|
||
back.objects.includes(b.id),
|
||
JSON.stringify({ gone, back }),
|
||
);
|
||
|
||
// 69g. Play runs the playhead from the start to the end of the last item, and stops there.
|
||
await evaluate(`__gm.composition.scrub(0)`);
|
||
const playAt = await json('__gm.composition.playAt()');
|
||
await mouse('mouseMoved', playAt.x, playAt.y);
|
||
await mouse('mousePressed', playAt.x, playAt.y);
|
||
await mouse('mouseReleased', playAt.x, playAt.y);
|
||
await sleep(500);
|
||
const running = await json('__gm.composition.view()');
|
||
let ended = running;
|
||
for (let i = 0; i < 40 && ended.playing; i++) {
|
||
await sleep(150);
|
||
ended = await json('__gm.composition.view()');
|
||
}
|
||
check(
|
||
'Play runs the playhead to the end and stops there',
|
||
running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 60,
|
||
JSON.stringify({ running, ended }),
|
||
);
|
||
|
||
// 69h. Two video items back to back: the second one's video is opened a second ahead, so the cut
|
||
// draws its picture in the very redraw it begins – loaded only then, it was left out until it came.
|
||
const uploaded = await uploadVideo(
|
||
t.baseUrl,
|
||
readFileSync(new URL('./fixtures/colors.webm', import.meta.url)),
|
||
'colors.webm',
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push(
|
||
{ id: '${VIDEO}', type: 'video', name: 'Colors 69', props: {}, file: ${JSON.stringify(uploaded.file)},
|
||
width: 320, height: 180, duration: 2, fps: 30, audio: true },
|
||
{ id: '${SCREEN}', type: 'sprite', name: 'Screen 69', props: { width: 160, height: 90, color: 0xffffff, texture: '${VIDEO}' } },
|
||
);
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
const object = (id) => ({ ...structuredClone(c.objects[0]), id, assetId: '${SCREEN}', overrides: {}, instanceEdits: {} });
|
||
c.objects = [object('ct69-v1'), object('ct69-v2')];
|
||
c.items = [
|
||
{ id: 'ct69-v1', track: 'ct69-t1', start: 0, length: 20, offset: 0 },
|
||
{ id: 'ct69-v2', track: 'ct69-t1', start: 20, length: 10, offset: 1 },
|
||
];
|
||
c.clips = [];`);
|
||
const second = `__gm.videos().find((v) => v.key.endsWith(':ct69-v2')) ?? null`;
|
||
await evaluate(`__gm.composition.scrub(5)`);
|
||
await sleep(300);
|
||
const early = await json(second);
|
||
await evaluate(`__gm.composition.scrub(12)`);
|
||
let ahead = null;
|
||
// Settled: a seek still landing would hold the view a frame, which is no picture missing.
|
||
for (let i = 0; i < 40 && !(ahead?.ready && !ahead.seeking && !ahead.landing); i++) {
|
||
await sleep(150);
|
||
ahead = await json(second);
|
||
}
|
||
const cut = await json(
|
||
`(() => { __gm.composition.scrub(20); return __gm.composition.drawn('ct69-v2'); })()`,
|
||
);
|
||
check(
|
||
'a coming video item is opened a second ahead, at its offset, and drawn in the redraw it begins',
|
||
early === null && ahead?.ready && ahead.paused && Math.abs(ahead.time - 1) < 0.05 && cut !== null,
|
||
JSON.stringify({ early, ahead, cut }),
|
||
);
|
||
|
||
// 69i. Play at the end starts over at 0, where the item's video was never opened: the playhead
|
||
// waits for its first picture, so its first frames are seen – it ran on without them before.
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-v3' }];
|
||
c.items = [{ id: 'ct69-v3', track: 'ct69-t1', start: 0, length: 20, offset: 0 }];`);
|
||
await evaluate(`__gm.composition.scrub(20)`);
|
||
await sleep(200);
|
||
await mouse('mousePressed', playAt.x, playAt.y);
|
||
await mouse('mouseReleased', playAt.x, playAt.y);
|
||
const wait = JSON.parse(
|
||
await evaluate(`new Promise((done) => {
|
||
const loading = [];
|
||
const step = () => {
|
||
const player = __gm.videos().find((v) => v.key.endsWith(':ct69-v3'));
|
||
const { playhead } = __gm.composition.view();
|
||
if (player?.ready) return done(JSON.stringify({ loading, ready: playhead }));
|
||
if (player) loading.push(playhead);
|
||
if (loading.length > 300) return done(JSON.stringify({ loading, ready: null }));
|
||
requestAnimationFrame(step);
|
||
};
|
||
step();
|
||
})`),
|
||
);
|
||
await sleep(500);
|
||
const after = await json('__gm.composition.view()');
|
||
check(
|
||
'Play waits at a video item whose player is still loading, then runs on',
|
||
wait.loading.length > 0 &&
|
||
wait.loading.every((at) => at === 0) &&
|
||
wait.ready !== null &&
|
||
wait.ready < 0.5 &&
|
||
after.playhead > 2,
|
||
JSON.stringify({ frames: wait.loading.length, ready: wait.ready, after: after.playhead }),
|
||
);
|
||
|
||
// 69j. Ctrl+C copies the picked item. Ctrl+V pastes it under the pointer while that is over the
|
||
// lanes, on the track there; with the pointer off the lanes at the playhead, on the picked track.
|
||
for (let i = 0; i < 30 && (await json('__gm.composition.view()')).playing; i++) await sleep(150);
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-p1', assetId: '${BOX}' }];
|
||
c.items = [{ id: 'ct69-p1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }];`);
|
||
const original = await json(`__gm.composition.box('ct69-p1')`);
|
||
await t.click(original.x + original.w / 2, original.y + original.h / 2);
|
||
await press('c', { ctrl: true });
|
||
const lane = await json(`__gm.composition.laneAt('ct69-t2', 50)`);
|
||
await mouse('mouseMoved', lane.x, lane.y);
|
||
await press('v', { ctrl: true });
|
||
await evaluate(`__gm.composition.scrub(12)`);
|
||
await mouse('mouseMoved', playAt.x, playAt.y);
|
||
await press('v', { ctrl: true });
|
||
m = await model();
|
||
const copies = m.items.filter((i) => i.id !== 'ct69-p1');
|
||
const pickedNow = await json('__gm.state.selection');
|
||
check(
|
||
'Ctrl+V pastes a copied item under the pointer, else at the playhead on the picked track',
|
||
m.items.length === 3 &&
|
||
m.objects.length === 3 &&
|
||
copies[0]?.track === 'ct69-t2' &&
|
||
copies[0].start === 50 &&
|
||
copies[0].length === 30 &&
|
||
copies[1]?.track === 'ct69-t2' &&
|
||
copies[1].start === 12 &&
|
||
pickedNow?.objectIds?.[0] === copies[1].id,
|
||
JSON.stringify({ copies, picked: pickedNow?.objectIds }),
|
||
);
|
||
|
||
// 69k. A video dragged straight onto a track (#419) is an item as long as the video, wearing a sprite
|
||
// the composition keeps and the Assets panel leaves out; a second drop shares it, and undoing both
|
||
// takes the sprite along.
|
||
const kept = (id) =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
const object = c.objects.find((o) => o.id === ${JSON.stringify(id)});
|
||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||
return { item: c.items.find((i) => i.id === ${JSON.stringify(id)}) ?? null,
|
||
sprite: sprite ? { id: sprite.id, type: sprite.type, owner: sprite.owner, texture: sprite.props.texture } : null,
|
||
owned: p.assets.filter((a) => a.owner === '${CUT}').length };
|
||
})()`);
|
||
const videoDrop = await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 70)`));
|
||
const one = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||
const row = one.sprite ? await json(`__gm.assetRow('${one.sprite.id}')`) : 'none';
|
||
await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 100)`));
|
||
const two = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||
await evaluate('__gm.state.undo()');
|
||
await evaluate('__gm.state.undo()');
|
||
const undone = await kept('');
|
||
check(
|
||
'a video dropped on a track wears a sprite the composition keeps, shared by a second drop',
|
||
videoDrop?.what === 'item' &&
|
||
videoDrop.rejection === '' &&
|
||
one.item?.track === 'ct69-t2' &&
|
||
one.item.start === 70 &&
|
||
one.item.length === 20 &&
|
||
one.sprite?.type === 'sprite' &&
|
||
one.sprite.owner === CUT &&
|
||
one.sprite.texture === VIDEO &&
|
||
row === null &&
|
||
two.item?.start === 100 &&
|
||
two.sprite?.id === one.sprite.id &&
|
||
two.owned === 1 &&
|
||
undone.owned === 0,
|
||
JSON.stringify({ videoDrop, one, row, two, undone }),
|
||
);
|
||
|
||
// 69l. An item dragged in the picture follows the pointer while the button is down, not only when
|
||
// it is let go: the drag's preview each frame does not build the picture again from the document.
|
||
await evaluate('__gm.composition.scrub(15)');
|
||
await sleep(100);
|
||
const front = (await json('__gm.composition.shown()')).at(-1)?.id ?? '';
|
||
const startAt = await json(`__gm.composition.objectScreen('${front}')`);
|
||
const objectX = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === '${CUT}').objects.find((o) => o.id === '${front}').position.x`,
|
||
);
|
||
const xBefore = await objectX();
|
||
await mouse('mouseMoved', startAt.x, startAt.y);
|
||
await mouse('mousePressed', startAt.x, startAt.y);
|
||
for (let i = 1; i <= 6; i++) {
|
||
await mouse('mouseMoved', startAt.x + i * 10, startAt.y, 'left', { buttons: 1 });
|
||
await sleep(16);
|
||
}
|
||
await sleep(100);
|
||
const during = await json(`__gm.composition.objectScreen('${front}')`);
|
||
await mouse('mouseReleased', startAt.x + 60, startAt.y);
|
||
await sleep(150);
|
||
const xAfter = await objectX();
|
||
check(
|
||
'an item dragged in the picture follows the pointer while dragged and keeps the place let go',
|
||
Math.abs(during.x - startAt.x - 60) < 2 && xAfter > xBefore,
|
||
JSON.stringify({ front, startAt, during, xBefore, xAfter }),
|
||
);
|
||
|
||
// 69m. A band from an empty spot picks the items it touches, with Alt – from anywhere, an item too –
|
||
// the clips. Ctrl+C and Ctrl+V take the clips under the pointer; a second paste onto the first
|
||
// moves on until the clips fit, as clips of a track never overlap.
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
const like = structuredClone(c.objects[0]);
|
||
c.objects = ['ct69-b1', 'ct69-b2', 'ct69-b3'].map((id) => ({ ...like, id, assetId: '${BOX}' }));
|
||
c.items = [
|
||
{ id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 },
|
||
{ id: 'ct69-b2', track: 'ct69-t1', start: 40, length: 30, offset: 0 },
|
||
{ id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 },
|
||
];
|
||
c.clips = [
|
||
{ id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 0 },
|
||
{ id: 'ct69-k2', track: 'ct69-t2', animation: '${SLIDE}', start: 10 },
|
||
];`);
|
||
await sleep(100);
|
||
const empty = await json(`__gm.composition.laneAt('ct69-t2', 90)`);
|
||
const into = await json(`__gm.composition.laneAt('ct69-t1', 25)`);
|
||
await drag(empty.x, empty.y, into.x, into.y);
|
||
const banded = await json('__gm.state.selection');
|
||
const k1 = await json(`__gm.composition.box('ct69-k1')`);
|
||
const k2 = await json(`__gm.composition.box('ct69-k2')`);
|
||
await drag(k1.x + 1, k1.y - 3, k2.x + k2.w + 3, k2.y + k2.h - 1, { modifiers: { alt: true } });
|
||
const altBanded = await json('__gm.state.selection');
|
||
await press('c', { ctrl: true });
|
||
const pasteAt = await json(`__gm.composition.laneAt('ct69-t1', 50)`);
|
||
await mouse('mouseMoved', pasteAt.x, pasteAt.y);
|
||
await press('v', { ctrl: true });
|
||
const firstPaste = (await model()).clips.slice(2);
|
||
await press('v', { ctrl: true });
|
||
m = await model();
|
||
const secondPaste = m.clips.slice(4);
|
||
const pastedPick = await json('__gm.state.selection');
|
||
check(
|
||
'a band picks the items it touches, with Alt the clips, and copied clips paste where they fit',
|
||
JSON.stringify([...(banded?.objectIds ?? [])].sort()) ===
|
||
JSON.stringify(['ct69-b1', 'ct69-b2', 'ct69-b3']) &&
|
||
altBanded?.kind === 'composition' &&
|
||
JSON.stringify([...altBanded.clipIds].sort()) === JSON.stringify(['ct69-k1', 'ct69-k2']) &&
|
||
firstPaste.length === 2 &&
|
||
firstPaste[0].track === 'ct69-t1' &&
|
||
firstPaste[0].start === 50 &&
|
||
firstPaste[1].track === 'ct69-t2' &&
|
||
firstPaste[1].start === 60 &&
|
||
secondPaste.length === 2 &&
|
||
secondPaste[0].start - firstPaste[0].start >= 5 &&
|
||
secondPaste[0].start - firstPaste[0].start <= 6 &&
|
||
secondPaste[1].start - secondPaste[0].start === 10 &&
|
||
JSON.stringify(pastedPick?.clipIds) === JSON.stringify(secondPaste.map((c) => c.id)),
|
||
JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }),
|
||
);
|
||
|
||
// 69n. A dragged item takes the clips that begin on it along; with Alt pressed once the drag runs
|
||
// it goes alone. The clip of the item next to it stays where it is.
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.items = [
|
||
{ id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 },
|
||
{ id: 'ct69-b2', track: 'ct69-t1', start: 70, length: 30, offset: 0 },
|
||
{ id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 },
|
||
];
|
||
c.clips = [
|
||
{ id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 5 },
|
||
{ id: 'ct69-k2', track: 'ct69-t1', animation: '${SLIDE}', start: 75 },
|
||
];`);
|
||
await sleep(100);
|
||
const frameWidth =
|
||
((await json(`__gm.composition.laneAt('ct69-t1', 100)`)).x -
|
||
(await json(`__gm.composition.laneAt('ct69-t1', 0)`)).x) /
|
||
100;
|
||
/** Drags the item by `frames` from its middle – where no clip lies –, Alt held only while it moves. */
|
||
const dragItem = async (id, frames, alt) => {
|
||
const box = await json(`__gm.composition.box('${id}')`);
|
||
const from = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
|
||
const to = from.x + frames * frameWidth;
|
||
await mouse('mouseMoved', from.x, from.y);
|
||
await mouse('mousePressed', from.x, from.y);
|
||
for (let i = 1; i <= 12; i++) {
|
||
await mouse('mouseMoved', from.x + ((to - from.x) * i) / 12, from.y, 'left', {
|
||
buttons: 1,
|
||
modifiers: alt ? 1 : 0,
|
||
});
|
||
await sleep(16);
|
||
}
|
||
await mouse('mouseReleased', to, from.y);
|
||
await sleep(150);
|
||
const now = await model();
|
||
return {
|
||
from,
|
||
picked: (await json('__gm.state.selection'))?.objectIds,
|
||
item: now.items.find((i) => i.id === id)?.start,
|
||
clips: Object.fromEntries(now.clips.map((c) => [c.id, c.start])),
|
||
};
|
||
};
|
||
const along = await dragItem('ct69-b1', 20, false);
|
||
const alone = await dragItem('ct69-b1', -15, true);
|
||
check(
|
||
'a dragged item takes the clips that begin on it along, with Alt it goes alone',
|
||
along.item === 20 &&
|
||
along.clips['ct69-k1'] === 25 &&
|
||
along.clips['ct69-k2'] === 75 &&
|
||
alone.item === 5 &&
|
||
alone.clips['ct69-k1'] === 25 &&
|
||
alone.clips['ct69-k2'] === 75,
|
||
JSON.stringify({ frameWidth, along, alone }),
|
||
);
|
||
|
||
// 69o (#418). A sound dragged onto a track is an audio item as long as one pass of it, picked on
|
||
// the composition, the inspector naming it. Played from the middle, the composition plans it as a
|
||
// clip that far into the sound, on the item's own bus and to the item's end; Pause ends it, and a
|
||
// scrub plays nothing.
|
||
const sounds = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`);
|
||
const blip = sounds.find((item) => item.bytes === 4044);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({ id: 'ct69-sound', type: 'sound', name: 'Blip 69', props: {}, file: ${JSON.stringify(blip?.file ?? '')} });
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [];
|
||
c.items = [];
|
||
c.clips = [];`);
|
||
const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`);
|
||
const soundDrop = await dragAsset('ct69-sound', await json(`__gm.composition.laneAt('ct69-t2', 10)`));
|
||
m = await model();
|
||
const tone = m.items[0];
|
||
const tonePick = await json('__gm.state.selection');
|
||
const toneSection = await json(`__gm.section('SOUND ITEM')`);
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.items[0].length = 20;`);
|
||
await evaluate('__gm.composition.scrub(15)');
|
||
await sleep(200);
|
||
const scrubbed = (await json('__gm.sound.playbacks()')).filter((p) => p.name === `composition:${CUT}`);
|
||
const toneAt = await json('__gm.composition.playAt()');
|
||
await mouse('mouseMoved', toneAt.x, toneAt.y);
|
||
await t.click(toneAt.x, toneAt.y);
|
||
let heard = [];
|
||
for (let i = 0; i < 40 && !heard.some((p) => p.name === `composition:${CUT}` && p.clips.length > 0); i++) {
|
||
await sleep(50);
|
||
heard = await json('__gm.sound.playbacks()');
|
||
}
|
||
const toneView = await json('__gm.composition.view()');
|
||
const toneSound = await json('__gm.composition.sound()');
|
||
const playing = heard.find((p) => p.name === `composition:${CUT}`);
|
||
const toneClip = playing?.clips[0];
|
||
await t.click(toneAt.x, toneAt.y);
|
||
await sleep(300);
|
||
const paused = (await json('__gm.sound.playbacks()')).filter((p) => p.name === `composition:${CUT}`);
|
||
check(
|
||
'a sound dragged onto a track is an audio item, heard from the playhead on its own bus until the item ends',
|
||
seconds > 0 &&
|
||
soundDrop?.what === 'item' &&
|
||
tone?.audio?.kind === 'sound' &&
|
||
tone.audio.asset === 'ct69-sound' &&
|
||
tone.audio.bus === '#composition' &&
|
||
tone.start === 10 &&
|
||
tone.length === Math.max(1, Math.round(seconds * 10)) &&
|
||
m.objects.length === 0 &&
|
||
tonePick?.kind === 'composition' &&
|
||
JSON.stringify(tonePick.itemIds) === JSON.stringify([tone.id]) &&
|
||
toneSection !== null &&
|
||
scrubbed.length === 0 &&
|
||
toneClip?.sound === 'ct69-sound' &&
|
||
toneClip.bus === `composition:${CUT}:${tone.id}` &&
|
||
toneSound.playing &&
|
||
toneSound.playings.includes(playing.id) &&
|
||
toneClip.offset > 0.5 &&
|
||
toneClip.offset < 0.8 &&
|
||
Math.abs(toneClip.offset + toneClip.duration - 2) < 0.002 &&
|
||
paused.length === 0,
|
||
JSON.stringify({
|
||
seconds,
|
||
soundDrop,
|
||
tone,
|
||
tonePick,
|
||
scrubbed,
|
||
toneClip,
|
||
paused: paused.length,
|
||
toneView,
|
||
heard,
|
||
toneSection,
|
||
toneSound,
|
||
}),
|
||
);
|
||
|
||
// 69o-ii. A pattern and a song leave the Assets panel as well and land as audio items as long as
|
||
// their preview promised – the panel once let only sounds go.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push(
|
||
{ id: 'ct69-pattern', type: 'pattern', name: 'Riff 69', props: { bars: 1 }, notes: [] },
|
||
{ id: 'ct69-song', type: 'song', name: 'Song 69', props: {}, tracks: [], items: [], graph: { nodes: [], edges: [] } },
|
||
);
|
||
p.assets.find((a) => a.id === '${CUT}').items = [];`);
|
||
const patternDrop = await dragAsset('ct69-pattern', await json(`__gm.composition.laneAt('ct69-t1', 10)`));
|
||
const songDrop = await dragAsset('ct69-song', await json(`__gm.composition.laneAt('ct69-t2', 10)`));
|
||
m = await model();
|
||
const kinds = m.items.map((i) => ({
|
||
kind: i.audio?.kind,
|
||
asset: i.audio?.asset,
|
||
start: i.start,
|
||
length: i.length,
|
||
}));
|
||
const landed = (drop, kind) =>
|
||
drop?.what === 'item' &&
|
||
kinds.some(
|
||
(i) => i.kind === kind && i.asset === drop.assetId && i.start === 10 && i.length === drop.length,
|
||
);
|
||
check(
|
||
'a pattern and a song dragged onto a track are audio items as long as their preview',
|
||
landed(patternDrop, 'pattern') && landed(songDrop, 'song') && m.items.length === 2,
|
||
JSON.stringify({ patternDrop, songDrop, kinds }),
|
||
);
|
||
|
||
// 69p. The handle on the top left corner of a sound item lights up under the pointer and drags
|
||
// its fade in out to 10 frames; played from the item's start, its bus rises from silence over that
|
||
// second and stays up.
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === '${CUT}').items = [
|
||
{ id: 'ct69-fade', track: 'ct69-t2', start: 10, length: 40, offset: 0,
|
||
audio: { kind: 'sound', asset: 'ct69-sound', volume: 0, bus: '' } },
|
||
];`);
|
||
await evaluate('__gm.composition.scrub(0)');
|
||
await sleep(100);
|
||
const fadeBox = await json(`__gm.composition.box('ct69-fade')`);
|
||
const perFrame = (await json('__gm.composition.view()')).pxPerTick;
|
||
await mouse('mouseMoved', fadeBox.x + 1, fadeBox.y + 3);
|
||
await sleep(50);
|
||
const fadeHover = (await json('__gm.composition.view()')).hover;
|
||
await drag(fadeBox.x + 1, fadeBox.y + 3, fadeBox.x + 10 * perFrame, fadeBox.y + 3);
|
||
const faded = (await model()).items[0];
|
||
await evaluate('__gm.composition.scrub(10)');
|
||
const fadeAt = await json('__gm.composition.playAt()');
|
||
await t.click(fadeAt.x, fadeAt.y);
|
||
const levels = [];
|
||
for (let i = 0; i < 16; i++) {
|
||
await sleep(100);
|
||
const bus = (await json('__gm.song.buses()')).find((b) =>
|
||
b.name.endsWith(`composition:${CUT}:ct69-fade`),
|
||
);
|
||
levels.push(bus ? Number(bus.level.toFixed(3)) : null);
|
||
}
|
||
await t.click(fadeAt.x, fadeAt.y);
|
||
const heardLevels = levels.filter((l) => l !== null);
|
||
check(
|
||
'the fade handle of a sound item drags its fade in, and its bus rises from silence over it',
|
||
fadeHover?.id === 'ct69-fade' &&
|
||
fadeHover.part === 'fadeIn' &&
|
||
faded?.fadeIn === 10 &&
|
||
faded.fadeOut === undefined &&
|
||
heardLevels[0] < 0.5 &&
|
||
heardLevels.some((l) => l > 0.1 && l < 0.9) &&
|
||
heardLevels.at(-1) > 0.99,
|
||
JSON.stringify({ fadeBox, perFrame, fadeHover, faded, levels }),
|
||
);
|
||
|
||
// 69q. A video dragged in: its sprite's Video clock reads as driven by the composition, its Video
|
||
// time – the composition's – is not shown, its Video bus goes into the composition by default. The
|
||
// composition has a node of its own in the master routing, linked to the master.
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [];
|
||
c.items = [];`);
|
||
await dragAsset(SCREEN, await json(`__gm.composition.laneAt('ct69-t1', 0)`));
|
||
const screenItem = (await model()).items[0];
|
||
// A row is looked for once it is scrolled into view: the inspector is taller than the window.
|
||
const videoRows = await json(`({
|
||
driven: __gm.inspectorField('Video clock') !== null && __gm.locate('Driven (by composition)') !== null,
|
||
time: __gm.inspectorField('Video time'),
|
||
volume: __gm.inspectorField('Video volume') !== null,
|
||
bus: __gm.inspectorField('Video bus') !== null && __gm.locate('To composition') !== null,
|
||
stored: __gm.state.project.assets.find((a) => a.id === '${CUT}').objects[0]?.overrides ?? null,
|
||
})`);
|
||
const routing = await json('__gm.routing.graph()');
|
||
const node = routing.nodes.find((n) => n.id === CUT);
|
||
const links = routing.edges.filter((e) => e.from.node === CUT).map((e) => e.to.node);
|
||
check(
|
||
'a video item reads as driven by the composition, goes into its bus, and the composition is routed',
|
||
screenItem !== undefined &&
|
||
videoRows.driven &&
|
||
videoRows.time === null &&
|
||
videoRows.volume &&
|
||
videoRows.bus &&
|
||
videoRows.stored?.videoClock === undefined &&
|
||
node?.params.kind === 'composition' &&
|
||
JSON.stringify(links) === JSON.stringify(['#master']),
|
||
JSON.stringify({ screenItem, videoRows, node, links }),
|
||
);
|
||
|
||
// 69r. A hitch of half a second while the tab plays: the playhead counts a tenth of it at most, the
|
||
// sound ran on – it is started again where the playhead is, and the two keep together after.
|
||
await mutate(`
|
||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [];
|
||
c.items = [{ id: 'ct69-long', track: 'ct69-t2', start: 0, length: 100, offset: 0,
|
||
audio: { kind: 'sound', asset: 'ct69-sound', volume: 0, bus: '' } }];`);
|
||
await evaluate('__gm.composition.scrub(0)');
|
||
const longAt = await json('__gm.composition.playAt()');
|
||
await t.click(longAt.x, longAt.y);
|
||
let hitchBefore = null;
|
||
for (let i = 0; i < 40 && !(hitchBefore?.playing && hitchBefore.ahead !== null); i++) {
|
||
await sleep(50);
|
||
hitchBefore = await json('__gm.composition.sound()');
|
||
}
|
||
await sleep(300);
|
||
hitchBefore = await json('__gm.composition.sound()');
|
||
await evaluate(`{ const until = performance.now() + 500; while (performance.now() < until); null; }`);
|
||
await sleep(400);
|
||
const hitchAfter = await json('__gm.composition.sound()');
|
||
await t.click(longAt.x, longAt.y);
|
||
check(
|
||
'after a hitch the sound of the tab is started again where the playhead is',
|
||
hitchBefore?.playing &&
|
||
Math.abs(hitchBefore.ahead) < 0.06 &&
|
||
hitchAfter.resyncs > hitchBefore.resyncs &&
|
||
hitchAfter.playing &&
|
||
Math.abs(hitchAfter.ahead) < 0.06,
|
||
JSON.stringify({ hitchBefore, hitchAfter }),
|
||
);
|
||
|
||
// 69s (#425). The clips of a sound and of a video have rows of their own under it in the Assets
|
||
// panel, folded until its chevron opens them. Each dragged onto a track is an item as long as the
|
||
// clip: the sound's an audio item playing it, the video's wearing the sprite of its video with the
|
||
// clip as texture, its picture from the clip's start. Its Texture label links the clip: a click
|
||
// pings its row, a double-click opens the video with the clip picked.
|
||
// The drone of the seed lasts 0.75 s, so its clip of 0.5 s is told apart from it.
|
||
const drone = sounds.find((item) => item.bytes === 12044);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.find((a) => a.id === '${VIDEO}').clips = [{ id: 'ct69-vclip', name: 'Middle', props: { start: 0.5, end: 1.5 } }];
|
||
p.assets.push({ id: 'ct69-drone', type: 'sound', name: 'Drone 69', props: {}, file: ${JSON.stringify(drone?.file ?? '')},
|
||
clips: [{ id: 'ct69-sclip', name: 'Bit', props: { start: 0.2, end: 0.7 } }] });
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
c.objects = [];
|
||
c.items = [];
|
||
c.clips = [];`);
|
||
await evaluate('__gm.composition.scrub(0)');
|
||
await json(`__gm.assetRow('${VIDEO}')`);
|
||
const folded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||
const chevron = await json(`__gm.assetChevron('${VIDEO}')`);
|
||
if (chevron) await t.click(chevron.x, chevron.y);
|
||
const unfolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||
/** Like `dragAsset`, but held over the lane until the drop is as long as `frames`: a cut decodes first. */
|
||
const dragClip = async (id, to, frames) => {
|
||
await sleep(450);
|
||
const from = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||
// No row, nothing to drag: the check says so rather than the module throwing.
|
||
if (!from) return null;
|
||
await mouse('mouseMoved', from.x, from.y);
|
||
await mouse('mousePressed', from.x, from.y);
|
||
for (let i = 1; i <= 8; i++) {
|
||
await mouse(
|
||
'mouseMoved',
|
||
from.x + ((to.x - from.x) * i) / 8,
|
||
from.y + ((to.y - from.y) * i) / 8,
|
||
'left',
|
||
{
|
||
buttons: 1,
|
||
},
|
||
);
|
||
await sleep(16);
|
||
}
|
||
let drop = await json('__gm.composition.drop()');
|
||
for (let i = 0; i < 20 && drop?.length !== frames; i++) {
|
||
await sleep(50);
|
||
await mouse('mouseMoved', to.x + (i % 2), to.y, 'left', { buttons: 1 });
|
||
drop = await json('__gm.composition.drop()');
|
||
}
|
||
await mouse('mouseReleased', to.x, to.y);
|
||
await sleep(200);
|
||
return drop;
|
||
};
|
||
const videoClipDrop = await dragClip(
|
||
'ct69-vclip',
|
||
await json(`__gm.composition.laneAt('ct69-t1', 40)`),
|
||
10,
|
||
);
|
||
const soundClipDrop = await dragClip('ct69-sclip', await json(`__gm.composition.laneAt('ct69-t2', 10)`), 5);
|
||
const placed = await json(`(() => {
|
||
const p = __gm.state.project;
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
const pictureItem = c.items.find((i) => !i.audio);
|
||
const object = c.objects.find((o) => o.id === pictureItem?.id);
|
||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||
return { pictureItem, audioItem: c.items.find((i) => i.audio) ?? null, overrides: object?.overrides ?? null,
|
||
sprite: sprite ? { owner: sprite.owner, texture: sprite.props.texture } : null };
|
||
})()`);
|
||
const clipPlayer = `__gm.videos().find((v) => v.key.includes('#0.5~1.5~') && v.key.endsWith(':${placed.pictureItem?.id}')) ?? null`;
|
||
await evaluate('__gm.composition.scrub(43)');
|
||
let middle = null;
|
||
for (let i = 0; i < 40 && !(middle?.ready && !middle.seeking && !middle.landing); i++) {
|
||
await sleep(150);
|
||
middle = await json(clipPlayer);
|
||
}
|
||
const middleDrawn = await json(`__gm.composition.drawn('${placed.pictureItem?.id}')`);
|
||
check(
|
||
'clips have folded rows of their own, and a clip of a video and of a sound lands as long as the clip',
|
||
folded === null &&
|
||
chevron !== null &&
|
||
unfolded !== null &&
|
||
videoClipDrop?.what === 'item' &&
|
||
videoClipDrop.rejection === '' &&
|
||
placed.pictureItem?.start === 40 &&
|
||
placed.pictureItem.length === 10 &&
|
||
placed.pictureItem.fitMedia === true &&
|
||
placed.overrides?.texture === 'ct69-vclip' &&
|
||
placed.sprite?.owner === CUT &&
|
||
placed.sprite.texture === VIDEO &&
|
||
drone !== undefined &&
|
||
soundClipDrop?.what === 'item' &&
|
||
placed.audioItem?.audio?.asset === 'ct69-sclip' &&
|
||
placed.audioItem.start === 10 &&
|
||
placed.audioItem.length === 5 &&
|
||
Math.abs((middle?.time ?? 0) - 0.8) < 0.05 &&
|
||
middleDrawn !== null,
|
||
JSON.stringify({ folded, chevron, unfolded, videoClipDrop, soundClipDrop, placed, middle, middleDrawn }),
|
||
);
|
||
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${placed.pictureItem?.id}' })`,
|
||
);
|
||
await sleep(200);
|
||
// Folded again, so the ping has to unfold the clips itself.
|
||
const again = await json(`__gm.assetChevron('${VIDEO}')`);
|
||
if (again) await t.click(again.x, again.y);
|
||
const refolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||
const textureLink = await json(`__gm.inspectorLink('Texture')`);
|
||
if (textureLink) await t.click(textureLink.x, textureLink.y);
|
||
const clipPing = { ping: await json('__gm.assetPing()'), selection: await json('__gm.state.selection') };
|
||
await sleep(1200);
|
||
const relink = await json(`__gm.inspectorLink('Texture')`);
|
||
if (relink) await t.doubleClick(relink.x, relink.y);
|
||
await sleep(200);
|
||
const clipOpened = { selection: await json('__gm.state.selection'), docs: await json('__gm.documents()') };
|
||
check(
|
||
'the Texture label links the clip: a click pings its row, a double-click opens the video with the clip picked',
|
||
refolded === null &&
|
||
textureLink?.underlined === true &&
|
||
textureLink.pressable === true &&
|
||
clipPing.ping?.id === 'ct69-vclip' &&
|
||
clipPing.selection?.kind === 'object' &&
|
||
clipOpened.selection?.kind === 'clip' &&
|
||
clipOpened.selection.clipId === 'ct69-vclip' &&
|
||
clipOpened.docs.active === `asset:${VIDEO}`,
|
||
JSON.stringify({ refolded, textureLink, clipPing, clipOpened }),
|
||
);
|
||
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`);
|
||
}
|