Julian BILCKE commited on
Commit
c608ace
·
unverified ·
2 Parent(s): 1103e30 4cfc8b3

Merge pull request #18 from lalalune/moon/entity-editor

Browse files
src/components/editors/EntityEditor/index.tsx CHANGED
@@ -1,88 +1,219 @@
1
- import { TimelineSegment, useTimeline } from "@aitube/timeline"
 
 
 
 
 
 
 
 
2
 
3
- import { FormFile } from "@/components/forms/FormFile"
4
- import { FormInput } from "@/components/forms/FormInput"
5
- import { FormSection } from "@/components/forms/FormSection"
6
- import { useEntityEditor, useRenderer } from "@/services"
7
- import { useEffect } from "react"
8
- import { ClapEntity, ClapProject } from "@aitube/clap"
9
 
10
- export function EntityEditor() {
11
- const clap: ClapProject = useTimeline(s => s.clap)
12
-
13
- const segmentsChanged: number = useTimeline(s => s.segmentsChanged)
14
- const selectedSegments: TimelineSegment[] = useTimeline(s => s.selectedSegments)
15
- const { activeSegments } = useRenderer(s => s.bufferedSegments)
16
-
17
- const current = useEntityEditor(s => s.current)
18
- const setCurrent = useEntityEditor(s => s.setCurrent)
19
- const history = useEntityEditor(s => s.history)
20
- const undo = useEntityEditor(s => s.undo)
21
- const redo = useEntityEditor(s => s.redo)
22
-
23
- let segment = selectedSegments.at(-1)
24
- let entity: ClapEntity | undefined = clap.entityIndex[segment?.entityId as any]
25
-
26
- if (!entity) {
27
- segment = activeSegments.find(s => clap.entityIndex[s?.entityId as any])
28
- entity = clap.entityIndex[segment?.entityId as any]
29
- }
30
 
31
  useEffect(() => {
32
- setCurrent(entity)
33
- }, [clap, entity, segmentsChanged])
 
 
34
 
35
- if (!current) {
36
- return <div>
37
- No Entity selected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  </div>
39
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- // TODO: adapt the editor based on the kind of
42
- // entity (character, location..)
43
- //
44
- // I think we can use UI elements of our legacy character editor
45
- // that I did in a Hugging Face space
46
  return (
47
- <FormSection
48
- label={"Entity editor"}
49
- className="p-4">
50
- <label>Visual identity</label>
51
- {current?.imageId
52
- ? <img src={current?.imageId}></img>
53
- : null}
54
- <FormFile
55
- label={"Visual identity file (TODO)"}
56
- />
57
- {/*
58
- <FormInput<string>
59
- label={"Audio identity"}
60
- value={current?.audioId.slice(0, 20)}
61
- />
62
- */}
63
- <FormFile
64
- label={"Audio identity file (TODO)"}
65
- />
66
- <FormInput<string>
67
- label={"Label"}
68
- value={current.label}
69
- />
70
- <FormInput<string>
71
- label={"Description"}
72
- value={current.description}
73
- />
74
- <FormInput<number>
75
- label={"Age"}
76
- value={current.age}
77
- />
78
- <FormInput<string>
79
- label={"Gender"}
80
- value={current.gender}
81
- />
82
- <FormInput<string>
83
- label={"Appearance"}
84
- value={current.appearance}
85
- />
86
- </FormSection>
87
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
 
1
+ import { FormFile } from "@/components/forms/FormFile";
2
+ import { FormInput } from "@/components/forms/FormInput";
3
+ import { FormSection } from "@/components/forms/FormSection";
4
+ import { FormSelect } from "@/components/forms/FormSelect";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useEntityEditor, useIO } from "@/services";
7
+ import { ClapEntity, ClapSegmentCategory } from "@aitube/clap";
8
+ import { useTimeline } from "@aitube/timeline";
9
+ import { useEffect, useState } from "react";
10
 
11
+ function EntityList({ onSelectEntity }: {
12
+ onSelectEntity: (entityId: string) => void;
13
+ }) {
14
+ const { entities, selectEntity, addEntity, removeEntity, current } = useEntityEditor();
 
 
15
 
16
+ const handleAddEntity = () => {
17
+ const newEntity: ClapEntity = {
18
+ id: Date.now().toString(),
19
+ label: "NEW_ENTITY",
20
+ category: ClapSegmentCategory.CHARACTER,
21
+ description: "",
22
+ appearance: "",
23
+ } as ClapEntity; // ignoring some fields for now
24
+ addEntity(newEntity);
25
+ };
 
 
 
 
 
 
 
 
 
 
26
 
27
  useEffect(() => {
28
+ if (entities.length > 0 && !current) {
29
+ selectEntity(entities[0].id);
30
+ }
31
+ }, [entities, current, selectEntity]);
32
 
33
+ return (
34
+ <div className="pt-4">
35
+ <div className="mb-2">
36
+ <h1 className="px-4 mb-4 text-xl font-bold inline">Entities</h1>
37
+ <Button onClick={handleAddEntity} className="absolute top-2 right-2" variant="secondary">
38
+ New +
39
+ </Button>
40
+ </div>
41
+ <ul>
42
+ {entities.map((entity: ClapEntity) => (
43
+ <li key={entity.id} className={`flex py-1 px-2`}>
44
+ <Button
45
+ onClick={() => {
46
+ selectEntity(entity.id);
47
+ onSelectEntity(entity.id);
48
+ }}
49
+ variant="ghost"
50
+ >
51
+ {entity.label} ({entity.category})
52
+ </Button>
53
+ <Button onClick={() => removeEntity(entity.id)} className={`ml-2 ml-auto`} variant="destructive" size="sm">
54
+ Remove
55
+ </Button>
56
+ </li>
57
+ ))}
58
+ </ul>
59
  </div>
60
+ );
61
+ }
62
+
63
+ export function EntityEditor() {
64
+ const { current, updateEntity } = useEntityEditor();
65
+ const { exportEntity, importEntity } = useIO();
66
+ const [localEntity, setLocalEntity] = useState<ClapEntity | null>(null);
67
+ const [showEntityList, setShowEntityList] = useState(true);
68
+
69
+ const { loadEntities } = useEntityEditor();
70
+ const clap = useTimeline((s) => s.clap);
71
+
72
+ useEffect(() => {
73
+ if (clap && clap.entities) {
74
+ loadEntities(clap.entities);
75
+ }
76
+ }, [clap, loadEntities]);
77
+
78
+ useEffect(() => {
79
+ if (current) {
80
+ setLocalEntity(current);
81
+ }
82
+ }, [current]);
83
+
84
+ const handleInputChange = (field: keyof ClapEntity, value: string | number | undefined) => {
85
+ if (localEntity) {
86
+ let updatedValue = value;
87
+ if (field === "age") {
88
+ updatedValue = value === "" ? undefined : parseInt(value as string);
89
+ }
90
+ if (field === "label") {
91
+ updatedValue = value?.toString().toUpperCase();
92
+ }
93
+ setLocalEntity((prevEntity) => ({
94
+ ...prevEntity,
95
+ [field]: updatedValue,
96
+ } as ClapEntity));
97
+ }
98
+ };
99
+
100
+ const handleSave = () => {
101
+ if (localEntity) {
102
+ updateEntity(localEntity);
103
+ }
104
+ };
105
+
106
+ const handleFileUpload = async (field: "imageId" | "audioId", file: File) => {
107
+ if (localEntity) {
108
+ const dataUrl = await new Promise<string>((resolve) => {
109
+ const reader = new FileReader();
110
+ reader.onload = (e) => resolve(e.target?.result as string);
111
+ reader.readAsDataURL(file);
112
+ });
113
+ setLocalEntity((prevEntity) => ({
114
+ ...prevEntity,
115
+ [field]: dataUrl,
116
+ } as ClapEntity));
117
+ }
118
+ };
119
+
120
+ const handleExport = async () => {
121
+ if (localEntity) {
122
+ await exportEntity(localEntity);
123
+ }
124
+ };
125
+
126
+ const handleImport = async (file: File) => {
127
+ const entity = await importEntity(file);
128
+ if (entity) {
129
+ setLocalEntity(entity);
130
+ }
131
+ };
132
+
133
+ const handleBack = () => {
134
+ setShowEntityList(true);
135
+ };
136
+
137
+ const handleSelectEntity = (entityId: string) => {
138
+ setShowEntityList(false);
139
+ };
140
 
 
 
 
 
 
141
  return (
142
+ <div className="flex flex-col w-full h-full overflow-x-auto">
143
+ {showEntityList ? (
144
+ <div className="mb-4">
145
+ <EntityList onSelectEntity={handleSelectEntity} />
146
+ </div>
147
+ ) : (
148
+ <div className="flex">
149
+ {localEntity && (
150
+ <FormSection className="px-2">
151
+ <Button onClick={handleBack}>Back</Button>
152
+ <FormInput
153
+ label="Identifier (UPPERCASE)"
154
+ value={localEntity.label || ""}
155
+ onChange={(value) => handleInputChange("label", value)}
156
+ />
157
+ <FormSelect
158
+ label="Category"
159
+ selectedItemId={localEntity.category}
160
+ items={Object.values(ClapSegmentCategory).map((category) => ({
161
+ id: category,
162
+ label: category,
163
+ value: category,
164
+ }))}
165
+ onSelect={(value) => handleInputChange("category", value)}
166
+ />
167
+ {/* ... form fields ... */}
168
+ <FormFile
169
+ label="Visual Identity"
170
+ onChange={(files) => files[0] && handleFileUpload("imageId", files[0])}
171
+ />
172
+ {localEntity.imageId && (
173
+ <div className="mt-2">
174
+ <img src={localEntity.imageId} alt="Entity Preview" className="max-w-full h-auto" />
175
+ </div>
176
+ )}
177
+ <FormFile
178
+ label="Audio Identity"
179
+ onChange={(files) => files[0] && handleFileUpload("audioId", files[0])}
180
+ />
181
+ {localEntity.audioId && (
182
+ <div className="mt-2">
183
+ <audio controls src={localEntity.audioId} />
184
+ </div>
185
+ )}
186
+ <FormInput
187
+ label="Description"
188
+ value={localEntity.description || ""}
189
+ onChange={(value) => handleInputChange("description", value)}
190
+ />
191
+ <FormInput
192
+ label="Appearance"
193
+ value={localEntity.appearance || ""}
194
+ onChange={(value) => handleInputChange("appearance", value)}
195
+ />
196
+ <FormInput
197
+ label="Age"
198
+ value={localEntity.age?.toString() || ""}
199
+ onChange={(value) => handleInputChange("age", value)}
200
+ />
201
+ <FormInput
202
+ label="Gender"
203
+ value={localEntity.gender || ""}
204
+ onChange={(value) => handleInputChange("gender", value)}
205
+ />
206
+ <div className="mt-4 flex space-x-2">
207
+ <Button onClick={handleSave}>Save</Button>
208
+ <Button onClick={handleExport}>Export</Button>
209
+ </div>
210
+ <div className="mt-4 flex space-x-2">
211
+ <FormFile label="Import" onChange={(files) => files[0] && handleImport(files[0])} />
212
+ </div>
213
+ </FormSection>
214
+ )}
215
+ </div>
216
+ )}
217
+ </div>
218
+ );
219
  }
src/services/editors/entity-editor/useEntityEditor.ts CHANGED
@@ -1,19 +1,67 @@
1
- "use client"
2
-
3
  import { create } from "zustand"
4
  import { ClapEntity } from "@aitube/clap"
5
  import { EntityEditorStore } from "@aitube/clapper-services"
 
6
 
7
  import { getDefaultEntityEditorState } from "./getDefaultEntityEditorState"
8
 
9
  export const useEntityEditor = create<EntityEditorStore>((set, get) => ({
10
  ...getDefaultEntityEditorState(),
11
- setCurrent: (current?: ClapEntity) => { set({ current }) },
 
 
 
 
 
 
12
  undo: () => {},
13
  redo: () => {},
14
- }))
15
-
16
-
17
- if (typeof window !== "undefined") {
18
- (window as any).useEntityEditor = useEntityEditor
19
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { create } from "zustand"
2
  import { ClapEntity } from "@aitube/clap"
3
  import { EntityEditorStore } from "@aitube/clapper-services"
4
+ import { useTimeline } from "@aitube/timeline"
5
 
6
  import { getDefaultEntityEditorState } from "./getDefaultEntityEditorState"
7
 
8
  export const useEntityEditor = create<EntityEditorStore>((set, get) => ({
9
  ...getDefaultEntityEditorState(),
10
+ current: undefined,
11
+ entities: [],
12
+ setCurrent: (current?: ClapEntity) => set({ current }),
13
+ selectEntity: (id: string) => {
14
+ const entity = get().entities.find(e => e.id === id)
15
+ set({ current: entity })
16
+ },
17
  undo: () => {},
18
  redo: () => {},
19
+ addEntity: (entity: ClapEntity) => {
20
+ set(state => {
21
+ const newEntities = [...state.entities, entity]
22
+ const timelineState = useTimeline.getState()
23
+ timelineState.setClap({
24
+ ...timelineState.clap,
25
+ entities: newEntities
26
+ })
27
+ return {
28
+ entities: newEntities,
29
+ current: entity
30
+ }
31
+ })
32
+ },
33
+ removeEntity: (id: string) => {
34
+ set(state => {
35
+ const newEntities = state.entities.filter(e => e.id !== id)
36
+ const timelineState = useTimeline.getState()
37
+ timelineState.setClap({
38
+ ...timelineState.clap,
39
+ entities: newEntities
40
+ })
41
+ return {
42
+ entities: newEntities,
43
+ current: state.current?.id === id ? undefined : state.current
44
+ }
45
+ })
46
+ },
47
+ updateEntity: (updatedEntity: ClapEntity) => {
48
+ set(state => {
49
+ const newEntities = state.entities.map(e =>
50
+ e.id === updatedEntity.id ? updatedEntity : e
51
+ )
52
+ return {
53
+ entities: newEntities,
54
+ current: updatedEntity
55
+ }
56
+ })
57
+ // Update the timeline state
58
+ const timelineState = useTimeline.getState()
59
+ timelineState.setClap({
60
+ ...timelineState.clap,
61
+ entities: get().entities
62
+ })
63
+ },
64
+ loadEntities: (entities: ClapEntity[]) => {
65
+ set({ entities, current: undefined })
66
+ },
67
+ }))
src/services/io/useIO.ts CHANGED
@@ -1,6 +1,6 @@
1
  "use client"
2
 
3
- import { ClapAssetSource, ClapProject, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType, newSegment, parseClap, serializeClap } from "@aitube/clap"
4
  import { TimelineStore, useTimeline, TimelineSegment } from "@aitube/timeline"
5
  import { ParseScriptProgressUpdate, parseScriptToClap } from "@aitube/broadway"
6
  import { TaskCategory, TaskVisibility } from "@aitube/clapper-services"
@@ -741,7 +741,28 @@ export const useIO = create<IOStore>((set, get) => ({
741
 
742
  saveOpenTimelineIO: async () => {
743
 
744
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  }))
746
 
747
 
 
1
  "use client"
2
 
3
+ import { ClapAssetSource, ClapEntity, ClapProject, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType, newSegment, parseClap, serializeClap } from "@aitube/clap"
4
  import { TimelineStore, useTimeline, TimelineSegment } from "@aitube/timeline"
5
  import { ParseScriptProgressUpdate, parseScriptToClap } from "@aitube/broadway"
6
  import { TaskCategory, TaskVisibility } from "@aitube/clapper-services"
 
741
 
742
  saveOpenTimelineIO: async () => {
743
 
744
+ },
745
+
746
+ exportEntity: async (entity: ClapEntity) => {
747
+ const blob = new Blob([JSON.stringify(entity, null, 2)], { type: 'application/json' })
748
+ const fileName = `${entity.label.toLowerCase()}.clap`
749
+ get().saveAnyFile(blob, fileName)
750
+ },
751
+
752
+ importEntity: async (file: File): Promise<ClapEntity | undefined> => {
753
+ try {
754
+ const text = await file.text()
755
+ const entity = JSON.parse(text) as ClapEntity
756
+ if (!entity.id || !entity.label || !entity.category) {
757
+ throw new Error('Invalid entity file format')
758
+ }
759
+ return entity
760
+ } catch (error) {
761
+ console.error('Error importing entity:', error)
762
+ // You might want to show an error message to the user here
763
+ return undefined
764
+ }
765
+ },
766
  }))
767
 
768