Skip to content

Commit 5e41ea5

Browse files
committed
WIP: collaboration
1 parent 8ca7403 commit 5e41ea5

File tree

17 files changed

+7364
-5199
lines changed

17 files changed

+7364
-5199
lines changed

docs/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"media-trimmer": "workspace:^",
1414
"webgl-effects": "workspace:^",
1515
"webgl-media-editor": "workspace:^",
16-
"webgl-video-editor": "workspace:^"
16+
"webgl-video-editor": "workspace:^",
17+
"y-indexeddb": "catalog:",
18+
"y-webrtc": "catalog:",
19+
"yjs": "catalog:"
1720
}
1821
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { getCurrentScope, markRaw, onScopeDispose, ref, type Ref } from 'vue'
2+
import { IndexeddbPersistence } from 'y-indexeddb'
3+
import { WebrtcProvider } from 'y-webrtc'
4+
import type * as Y from 'yjs'
5+
6+
import { promiseWithResolvers } from 'shared/utils'
7+
import { VideoEditorYjsStore } from 'webgl-video-editor/store/yjs.js'
8+
9+
const DOC_NAME = 'video-editor-demo-doc'
10+
11+
export const useVideoEditorStore = (ydoc: Y.Doc): Ref<VideoEditorYjsStore | undefined> => {
12+
const store = ref<VideoEditorYjsStore>()
13+
if (import.meta.env.SSR) return store
14+
15+
const scope = getCurrentScope()
16+
17+
const idbPromise = promiseWithResolvers()
18+
19+
void navigator.locks.request(DOC_NAME, async () => {
20+
if (!scope?.active) return
21+
22+
const { promise, resolve } = promiseWithResolvers()
23+
24+
const idb = new IndexeddbPersistence(DOC_NAME, ydoc)
25+
await idb.whenSynced
26+
idbPromise.resolve()
27+
28+
scope.run(() => {
29+
onScopeDispose(() => {
30+
void idb.destroy()
31+
resolve()
32+
})
33+
})
34+
35+
await promise
36+
})
37+
38+
const webrtc = new WebrtcProvider(DOC_NAME, ydoc)
39+
40+
void Promise.race([
41+
idbPromise.promise,
42+
new Promise<unknown>((resolve) => {
43+
ydoc.once('sync', resolve)
44+
webrtc.once('peers', () => ydoc.once('update', resolve))
45+
}),
46+
]).then(() => {
47+
store.value = markRaw(new VideoEditorYjsStore(ydoc))
48+
})
49+
50+
onScopeDispose(() => {
51+
webrtc.destroy()
52+
})
53+
54+
return store
55+
}

docs/video-editor.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ navbar: false
66

77
<script setup lang="ts">
88
import { useRouter } from 'vitepress'
9+
import * as Y from 'yjs'
10+
911
import { VideoEditorApp } from 'app-video-editor'
1012

1113
const router = useRouter()
14+
import { useVideoEditorStore } from './video-editor-demo/video-editor-demo-store'
15+
16+
const store = useVideoEditorStore(new Y.Doc())
1217
</script>
1318

1419
<div >
1520
<ClientOnly>
16-
<VideoEditorApp :onCloseProject="() => router.go('/')" />
21+
<VideoEditorApp v-if="store" :store :onCloseProject="() => router.go('/')" />
1722
</ClientOnly>
1823
</div>

packages/webgl-video-editor/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,17 @@
8181
"webm-muxer": "catalog:"
8282
},
8383
"peerDependencies": {
84-
"vue": "3"
84+
"vue": "3",
85+
"yjs": "catalog:",
86+
"yjs-orderedtree": "catalog:"
8587
},
8688
"peerDependenciesMeta": {
89+
"yjs": {
90+
"optional": true
91+
},
92+
"yjs-orderedtree": {
93+
"optional": true
94+
},
8795
"vue": {
8896
"optional": true
8997
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const YTREE_YMAP_KEY = 'ytree'
2+
export const YTREE_ROOT_KEY = 'root'
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { ref } from 'fine-jsx'
2+
import * as Y from 'yjs'
3+
import { YTree } from 'yjs-orderedtree'
4+
5+
import type {
6+
NodeCreateEvent,
7+
NodeDeleteEvent,
8+
NodeMoveEvent,
9+
NodeUpdateEvent,
10+
Schema,
11+
VideoEditor,
12+
VideoEditorStore,
13+
} from 'webgl-video-editor'
14+
15+
import { ASSET_TYPE_PREFIX, ROOT_NDOE_ID } from '../constants.ts'
16+
17+
import { YTREE_ROOT_KEY, YTREE_YMAP_KEY } from './constants.ts'
18+
import { createInitialMovie } from './utils.ts'
19+
20+
const updateYnode = (ynode: Y.Map<unknown>, updates: Partial<Schema.AnyNodeSchema>): void => {
21+
for (const key in updates) {
22+
if (Object.hasOwn(updates, key)) {
23+
const newValue = updates[key as keyof Schema.AnyNodeSchema]
24+
if (JSON.stringify(newValue) !== JSON.stringify(ynode.get(key))) ynode.set(key, newValue)
25+
}
26+
}
27+
}
28+
29+
const OBSERVED = new WeakSet<Y.Map<unknown>>()
30+
31+
export class VideoEditorYjsStore implements VideoEditorStore {
32+
#movie!: VideoEditor['_editor']['_movie']
33+
34+
readonly ydoc: Y.Doc
35+
#ytree!: YTree
36+
#yundo!: Y.UndoManager
37+
readonly #ignoreOrigin = Symbol('ignore-undo')
38+
39+
readonly #canUndo = ref(false)
40+
readonly #canRedo = ref(false)
41+
get canUndo(): boolean {
42+
return this.#canUndo.value
43+
}
44+
get canRedo(): boolean {
45+
return this.#canRedo.value
46+
}
47+
48+
readonly #abort = new AbortController()
49+
50+
#isApplyingDocUpdate = false
51+
get #shouldSkipNodeEvent(): boolean {
52+
return this.#yundo.undoing || this.#yundo.redoing || this.#isApplyingDocUpdate
53+
}
54+
55+
constructor(ydoc: Y.Doc) {
56+
this.ydoc = ydoc
57+
ydoc.on('destroy', this.dispose.bind(this))
58+
}
59+
60+
init(editor: VideoEditor): void {
61+
const { _editor } = editor
62+
63+
const ymap = this.ydoc.getMap(YTREE_YMAP_KEY)
64+
65+
this.#ytree = new YTree(ymap)
66+
const yundo = (this.#yundo = new Y.UndoManager(ymap))
67+
68+
let isNewMovie = true
69+
try {
70+
this.#ytree.getNodeValueFromKey(ROOT_NDOE_ID)
71+
isNewMovie = false
72+
} catch {}
73+
74+
const movie = (this.#movie = _editor._movie)
75+
movie.nodes.map.forEach((node) => this.#onCreate({ nodeId: node.id }))
76+
77+
this.#ytree.observe(this.#onYtreeChange.bind(this))
78+
79+
const bindNodeListener = <T extends unknown[]>(
80+
listener: (...args: T) => unknown,
81+
): ((...args: T) => void) => {
82+
listener = listener.bind(this)
83+
return (...args) => {
84+
if (!this.#shouldSkipNodeEvent) this.transact(() => listener(...args))
85+
}
86+
}
87+
88+
const options: AddEventListenerOptions = { signal: this.#abort.signal }
89+
/* eslint-disable @typescript-eslint/unbound-method -- false positive */
90+
movie.on('node:create', bindNodeListener(this.#onCreate), options)
91+
movie.on('node:move', bindNodeListener(this.#onMove), options)
92+
movie.on('node:update', bindNodeListener(this.#onUpdate), options)
93+
movie.on('node:delete', bindNodeListener(this.#onDelete), options)
94+
/* eslint-enable @typescript-eslint/unbound-method */
95+
96+
if (isNewMovie) {
97+
editor.replaceContent(createInitialMovie(this.generateId.bind(this)))
98+
_editor.createInitialAssets()
99+
}
100+
101+
movie.on('root:replace', this.reset.bind(this), options)
102+
this.#onYtreeChange()
103+
104+
const onStackChange = (): void => {
105+
const yundo = this.#yundo
106+
this.#canUndo.value = yundo.canUndo()
107+
this.#canRedo.value = yundo.canRedo()
108+
}
109+
110+
yundo.ignoreRemoteMapChanges = true
111+
yundo.on('stack-cleared', onStackChange)
112+
yundo.on('stack-item-added', onStackChange)
113+
yundo.on('stack-item-popped', onStackChange)
114+
yundo.clear()
115+
}
116+
117+
// WIP: shouldn't be needed
118+
untracked<T>(fn: () => T): T {
119+
return this.ydoc.transact(fn, this.#ignoreOrigin)
120+
}
121+
122+
transact<T>(fn: () => T): T {
123+
return this.ydoc.transact(fn)
124+
}
125+
126+
undo(): void {
127+
this.#yundo.undo()
128+
}
129+
redo(): void {
130+
this.#yundo.redo()
131+
}
132+
133+
readonly #onYnodeChange = (event: Y.YMapEvent<unknown>): void => {
134+
this.#isApplyingDocUpdate = true
135+
136+
try {
137+
this.ydoc.transact(() => {
138+
const ynode = event.target
139+
const id = ynode.get('id') as string
140+
const node = this.#movie.nodes.get(id) as Record<string, any>
141+
142+
event.changes.keys.forEach((change, key) => {
143+
const newValue = change.action === 'delete' ? undefined : ynode.get(key)
144+
node[key] = newValue
145+
})
146+
})
147+
} finally {
148+
this.#isApplyingDocUpdate = false
149+
}
150+
}
151+
152+
#ensureObserved(ynode: Y.Map<unknown>): void {
153+
if (!OBSERVED.has(ynode)) {
154+
ynode.observe(this.#onYnodeChange)
155+
OBSERVED.add(ynode)
156+
}
157+
}
158+
159+
#onYtreeChange(): void {
160+
this.#isApplyingDocUpdate = true
161+
try {
162+
this.ydoc.transact(() => this.#onYtreeChange_(YTREE_ROOT_KEY))
163+
} finally {
164+
this.#isApplyingDocUpdate = false
165+
}
166+
}
167+
168+
#onYtreeChange_(parentKey: string): void {
169+
const ytree = this.#ytree
170+
const childIds: string[] = ytree.sortChildrenByOrder(ytree.getNodeChildrenFromKey(parentKey), parentKey)
171+
const childIdSet = new Set<string>(childIds)
172+
173+
const getOrCreateFromYTree = (ynode: Y.Map<unknown>) =>
174+
this.#movie.nodes.map.get(ynode.get('id') as string) ??
175+
this.#movie.createNode(ynode.toJSON() as Schema.AnyNodeSchema)
176+
177+
if (parentKey === YTREE_ROOT_KEY) {
178+
childIds.forEach((nodeId) => {
179+
const ynode: Y.Map<unknown> = ytree.getNodeValueFromKey(nodeId)
180+
if ((ynode.get('type') as string).startsWith(ASSET_TYPE_PREFIX)) getOrCreateFromYTree(ynode)
181+
})
182+
} else {
183+
this.#movie.nodes.get(parentKey).children?.forEach((child) => {
184+
if (!childIdSet.has(child.id)) child.remove()
185+
})
186+
}
187+
188+
childIds.forEach((nodeId, index) => {
189+
const ynode = ytree.getNodeValueFromKey(nodeId)
190+
const node = getOrCreateFromYTree(ynode)
191+
192+
this.#ensureObserved(ynode)
193+
194+
if (
195+
(node.parent?.id ?? YTREE_ROOT_KEY) !== parentKey ||
196+
(parentKey !== YTREE_ROOT_KEY && node.index !== index)
197+
)
198+
node.position(parentKey === YTREE_ROOT_KEY ? undefined : { parentId: parentKey, index })
199+
200+
this.#onYtreeChange_(nodeId)
201+
})
202+
}
203+
204+
#onCreate(event: Pick<NodeCreateEvent, 'nodeId'>): void {
205+
const { nodeId } = event
206+
const node = this.#movie.nodes.get(nodeId)
207+
const ytree = this.#ytree
208+
let ynode: Y.Map<unknown>
209+
210+
try {
211+
ynode = ytree.getNodeValueFromKey(nodeId)
212+
} catch {
213+
ynode = new Y.Map(Object.entries(node.toObject()))
214+
ytree.createNode(YTREE_ROOT_KEY, nodeId, ynode)
215+
ytree.recomputeParentsAndChildren()
216+
}
217+
218+
this.#ensureObserved(ynode)
219+
}
220+
221+
#onMove(event: NodeMoveEvent): void {
222+
const { nodeId } = event
223+
const node = this.#movie.nodes.get(nodeId)
224+
const parentNode = node.parent
225+
const ytree = this.#ytree
226+
227+
const parentKey = parentNode?.id ?? YTREE_ROOT_KEY
228+
if (ytree.getNodeParentFromKey(nodeId) !== parentKey) ytree.moveChildToParent(nodeId, parentKey)
229+
230+
if (parentNode) {
231+
const ytreeSiblingIds = new Set<string>(ytree.getNodeChildrenFromKey(parentKey))
232+
233+
ytree.recomputeParentsAndChildren()
234+
const nextId = node.next?.id
235+
const prevId = node.prev?.id
236+
237+
if (nextId && ytreeSiblingIds.has(nextId)) ytree.setNodeBefore(nodeId, nextId)
238+
else if (prevId && ytreeSiblingIds.has(prevId)) ytree.setNodeAfter(nodeId, prevId)
239+
}
240+
}
241+
242+
#onUpdate(event: NodeUpdateEvent): void {
243+
const { from, nodeId } = event
244+
const node = this.#movie.nodes.get(nodeId)
245+
const udpates: Record<string, unknown> = {}
246+
247+
for (const key in from) {
248+
if (Object.hasOwn(from, key)) {
249+
udpates[key] = node[key as keyof typeof node]
250+
}
251+
}
252+
253+
updateYnode(this.#ytree.getNodeValueFromKey(nodeId), udpates)
254+
}
255+
256+
#onDelete(event: NodeDeleteEvent): void {
257+
if (this.#shouldSkipNodeEvent) return
258+
259+
const { nodeId } = event
260+
261+
this.#ytree.deleteNodeAndDescendants(nodeId)
262+
}
263+
264+
reset(): void {
265+
this.#yundo.clear()
266+
}
267+
268+
dispose(): void {
269+
this.#abort.abort()
270+
}
271+
272+
generateId(): string {
273+
return this.#ytree.generateNodeKey()
274+
}
275+
276+
serializeYdoc(): Schema.SerializedMovie {
277+
const ytree = this.#ytree
278+
279+
const serialize = (nodeId: string): Schema.AnyNodeSerializedSchema => {
280+
const ynode: Y.Map<unknown> = ytree.getNodeValueFromKey(nodeId)
281+
const childIds: string[] = ytree.getNodeChildrenFromKey(nodeId)
282+
283+
return { ...ynode.toJSON(), children: childIds.map(serialize) } as any
284+
}
285+
286+
const movie = serialize(ytree.getNodeValueFromKey(ROOT_NDOE_ID)) as Schema.SerializedMovie
287+
288+
return movie
289+
}
290+
}

0 commit comments

Comments
 (0)