Spaces:
Build error
Build error
Merge pull request #18 from lalalune/moon/entity-editor
Browse files
src/components/editors/EntityEditor/index.tsx
CHANGED
@@ -1,88 +1,219 @@
|
|
1 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
import { useEffect } from "react"
|
8 |
-
import { ClapEntity, ClapProject } from "@aitube/clap"
|
9 |
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
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 |
-
|
33 |
-
|
|
|
|
|
34 |
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
<
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
undo: () => {},
|
13 |
redo: () => {},
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
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 |
|