@pixerate/editor-svelte 0.117.1 → 0.119.0
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/dist/effects/EffectParameters.svelte +5 -3
- package/dist/effects/EffectParameters.svelte.d.ts +3 -1
- package/dist/typography/FontPicker.svelte +243 -0
- package/dist/typography/FontPicker.svelte.d.ts +36 -0
- package/dist/typography/TypographyControl.svelte +73 -0
- package/dist/typography/TypographyControl.svelte.d.ts +26 -0
- package/dist/typography/index.d.ts +9 -0
- package/dist/typography/index.js +5 -0
- package/dist/typography/picker.svelte.d.ts +13 -0
- package/dist/typography/picker.svelte.js +19 -0
- package/dist/video-editor/ClipInspector.svelte +87 -3
- package/dist/video-editor/EditPlanPreview.svelte +72 -0
- package/dist/video-editor/EditPlanPreview.svelte.d.ts +10 -0
- package/dist/video-editor/TranscriptEditor.svelte +105 -0
- package/dist/video-editor/TranscriptEditor.svelte.d.ts +14 -0
- package/dist/video-editor/VideoEditor.svelte +14 -4
- package/dist/video-editor/VideoEditor.svelte.d.ts +6 -0
- package/dist/video-editor/VideoEffectsPanel.svelte +32 -1
- package/dist/video-editor/VideoTransport.svelte +10 -2
- package/dist/video-editor/createVideoEditor.svelte.d.ts +20 -1
- package/dist/video-editor/createVideoEditor.svelte.js +60 -1
- package/dist/video-editor/index.d.ts +6 -0
- package/dist/video-editor/index.js +2 -0
- package/package.json +10 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { effectFieldValue, effectParameterFields, type EffectDescription } from '@pixerate/editor/effects';
|
|
2
|
+
import { effectFieldValue, effectParameterFields, type EffectDescription, type EffectFieldOptions } from '@pixerate/editor/effects';
|
|
3
3
|
import { parameterLabel, parameterValuesEqual, type ParameterValue } from '@pixerate/editor/parameters';
|
|
4
4
|
import ParameterFieldControl from '../parameters/ParameterFieldControl.svelte';
|
|
5
5
|
|
|
@@ -23,12 +23,14 @@
|
|
|
23
23
|
onpresetname?: (name: string) => void;
|
|
24
24
|
/** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
|
|
25
25
|
addon?: import('svelte').Snippet<[string]>;
|
|
26
|
+
/** Families offered by font controls (`format: "font"`); default the system font catalog. */
|
|
27
|
+
fonts?: EffectFieldOptions['fonts'];
|
|
26
28
|
disabled?: boolean;
|
|
27
29
|
class?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
let { description, values, onchange, onreset, keyframed, onpreset, onpresetname, addon, disabled = false, class: className = '' }: Props = $props();
|
|
31
|
-
const fields = $derived(effectParameterFields(description));
|
|
32
|
+
let { description, values, onchange, onreset, keyframed, onpreset, onpresetname, addon, fonts, disabled = false, class: className = '' }: Props = $props();
|
|
33
|
+
const fields = $derived(effectParameterFields(description, { fonts }));
|
|
32
34
|
const idBase = `fx-${Math.random().toString(36).slice(2, 8)}`;
|
|
33
35
|
let gesture: string | null = null;
|
|
34
36
|
let count = 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EffectDescription } from '@pixerate/editor/effects';
|
|
1
|
+
import { type EffectDescription, type EffectFieldOptions } from '@pixerate/editor/effects';
|
|
2
2
|
import { type ParameterValue } from '@pixerate/editor/parameters';
|
|
3
3
|
interface Props {
|
|
4
4
|
/** The effect's description (`describe`). */
|
|
@@ -20,6 +20,8 @@ interface Props {
|
|
|
20
20
|
onpresetname?: (name: string) => void;
|
|
21
21
|
/** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
|
|
22
22
|
addon?: import('svelte').Snippet<[string]>;
|
|
23
|
+
/** Families offered by font controls (`format: "font"`); default the system font catalog. */
|
|
24
|
+
fonts?: EffectFieldOptions['fonts'];
|
|
23
25
|
disabled?: boolean;
|
|
24
26
|
class?: string;
|
|
25
27
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, untrack, type Snippet } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
FONT_CATEGORIES,
|
|
5
|
+
FONT_CATEGORY_LABELS,
|
|
6
|
+
FontPickerModel,
|
|
7
|
+
type FontCategory,
|
|
8
|
+
type FontProvider,
|
|
9
|
+
type FontSpec,
|
|
10
|
+
type FontStyle,
|
|
11
|
+
type FontSummary
|
|
12
|
+
} from '@pixerate/editor/typography';
|
|
13
|
+
import { fontPickerOf } from './picker.svelte.js';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** The selected font (controlled). */
|
|
17
|
+
value?: Partial<FontSpec>;
|
|
18
|
+
/** Called when the user (or an agent through the model's tools) picks a different font, weight or style. */
|
|
19
|
+
onchange?: (value: FontSpec) => void;
|
|
20
|
+
onChange?: (value: FontSpec) => void;
|
|
21
|
+
/** An existing model; when given, `provider`, `fonts` and `previewText` are ignored. */
|
|
22
|
+
model?: FontPickerModel;
|
|
23
|
+
/** Where fonts come from (fetched on mount). */
|
|
24
|
+
provider?: FontProvider;
|
|
25
|
+
/** A fixed list of fonts. */
|
|
26
|
+
fonts?: readonly FontSummary[];
|
|
27
|
+
/** Text for the live preview. */
|
|
28
|
+
previewText?: string;
|
|
29
|
+
/** Show the live preview (default true). */
|
|
30
|
+
showPreview?: boolean;
|
|
31
|
+
/** Show the category filter (default true). */
|
|
32
|
+
showCategories?: boolean;
|
|
33
|
+
/** Show the weight and style selects (default true). */
|
|
34
|
+
showWeight?: boolean;
|
|
35
|
+
/** Most options rendered at once (default 200); search to reach the rest. */
|
|
36
|
+
maxResults?: number;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
class?: string;
|
|
39
|
+
className?: string;
|
|
40
|
+
id?: string;
|
|
41
|
+
/** Accessible label for the group (default "Font picker"). */
|
|
42
|
+
'aria-label'?: string;
|
|
43
|
+
/** Extra controls rendered above the preview (used by `TypographyControl`). */
|
|
44
|
+
children?: Snippet;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
value,
|
|
49
|
+
onchange,
|
|
50
|
+
onChange,
|
|
51
|
+
model: external,
|
|
52
|
+
provider,
|
|
53
|
+
fonts,
|
|
54
|
+
previewText,
|
|
55
|
+
showPreview = true,
|
|
56
|
+
showCategories = true,
|
|
57
|
+
showWeight = true,
|
|
58
|
+
maxResults = 200,
|
|
59
|
+
disabled = false,
|
|
60
|
+
class: klass,
|
|
61
|
+
className,
|
|
62
|
+
id,
|
|
63
|
+
'aria-label': ariaLabel = 'Font picker',
|
|
64
|
+
children
|
|
65
|
+
}: Props = $props();
|
|
66
|
+
|
|
67
|
+
const model = untrack(() => external ?? new FontPickerModel({ provider, fonts, value, previewText }));
|
|
68
|
+
const picker = fontPickerOf(model);
|
|
69
|
+
const offValue = model.onValueChange((v) => (onchange ?? onChange)?.(v));
|
|
70
|
+
onDestroy(() => {
|
|
71
|
+
picker.destroy();
|
|
72
|
+
offValue();
|
|
73
|
+
});
|
|
74
|
+
if (!untrack(() => external) && untrack(() => provider)) void model.refresh();
|
|
75
|
+
|
|
76
|
+
$effect(() => {
|
|
77
|
+
const v = value;
|
|
78
|
+
if (v) untrack(() => model.setValue(v));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const s = $derived(picker.state);
|
|
82
|
+
const shown = $derived(s.results.slice(0, maxResults));
|
|
83
|
+
// The model's getters read its current state; reading `s` first makes them update with it.
|
|
84
|
+
const css = $derived.by(() => {
|
|
85
|
+
void s;
|
|
86
|
+
return model.css;
|
|
87
|
+
});
|
|
88
|
+
const weights = $derived.by(() => {
|
|
89
|
+
void s;
|
|
90
|
+
return model.availableWeights;
|
|
91
|
+
});
|
|
92
|
+
const styles = $derived.by(() => {
|
|
93
|
+
void s;
|
|
94
|
+
return model.availableStyles;
|
|
95
|
+
});
|
|
96
|
+
const status = $derived(
|
|
97
|
+
s.loading
|
|
98
|
+
? 'Loading fonts…'
|
|
99
|
+
: s.error
|
|
100
|
+
? `Could not load fonts: ${s.error}`
|
|
101
|
+
: s.loadStatus[s.value.family.toLowerCase()] === 'failed'
|
|
102
|
+
? `${s.value.family} could not be loaded; a fallback is shown.`
|
|
103
|
+
: ''
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const baseId = `pixerate-font-picker-${Math.random().toString(36).slice(2, 10)}`;
|
|
107
|
+
const listId = `${baseId}-list`;
|
|
108
|
+
const optionId = (i: number) => `${baseId}-opt-${i}`;
|
|
109
|
+
let listEl: HTMLUListElement | undefined = $state();
|
|
110
|
+
|
|
111
|
+
$effect(() => {
|
|
112
|
+
const i = s.highlighted;
|
|
113
|
+
if (i < 0) return;
|
|
114
|
+
untrack(() => listEl?.ownerDocument.getElementById(optionId(i))?.scrollIntoView?.({ block: 'nearest' }));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function onKeydown(e: KeyboardEvent) {
|
|
118
|
+
if (model.handleKey(e.key)) e.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const field =
|
|
122
|
+
'box-sizing:border-box;width:100%;padding:5px 8px;border-radius:6px;border:1px solid var(--pixerate-font-picker-border, #3a4152);background:var(--pixerate-font-picker-field-bg, #151922);color:inherit;font:inherit;';
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<div
|
|
126
|
+
{id}
|
|
127
|
+
role="group"
|
|
128
|
+
aria-label={ariaLabel}
|
|
129
|
+
aria-disabled={disabled || undefined}
|
|
130
|
+
class={klass ?? className}
|
|
131
|
+
style="box-sizing:border-box;width:var(--pixerate-font-picker-width, 280px);border-radius:var(--pixerate-font-picker-radius, 8px);background:var(--pixerate-font-picker-bg, #1f2430);color:var(--pixerate-font-picker-fg, #f0f2f5);padding:12px;display:flex;flex-direction:column;gap:8px;font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;font-size:12px;opacity:{disabled
|
|
132
|
+
? 0.6
|
|
133
|
+
: 1};pointer-events:{disabled ? 'none' : 'auto'};"
|
|
134
|
+
>
|
|
135
|
+
<div style="display:flex;gap:6px;">
|
|
136
|
+
<input
|
|
137
|
+
type="text"
|
|
138
|
+
role="combobox"
|
|
139
|
+
aria-label="Search fonts"
|
|
140
|
+
aria-expanded="true"
|
|
141
|
+
aria-controls={listId}
|
|
142
|
+
aria-autocomplete="list"
|
|
143
|
+
aria-activedescendant={s.highlighted >= 0 && s.highlighted < shown.length ? optionId(s.highlighted) : undefined}
|
|
144
|
+
placeholder="Search fonts"
|
|
145
|
+
value={s.query}
|
|
146
|
+
{disabled}
|
|
147
|
+
oninput={(e) => model.setQuery((e.currentTarget as HTMLInputElement).value)}
|
|
148
|
+
onkeydown={onKeydown}
|
|
149
|
+
style={field}
|
|
150
|
+
/>
|
|
151
|
+
{#if showCategories}
|
|
152
|
+
<select
|
|
153
|
+
aria-label="Font category"
|
|
154
|
+
value={s.category}
|
|
155
|
+
{disabled}
|
|
156
|
+
onchange={(e) => model.setCategory((e.currentTarget as HTMLSelectElement).value as FontCategory | 'all')}
|
|
157
|
+
style="{field}width:auto;"
|
|
158
|
+
>
|
|
159
|
+
{#each ['all', ...FONT_CATEGORIES] as c (c)}
|
|
160
|
+
<option value={c}>{FONT_CATEGORY_LABELS[c as FontCategory | 'all']}</option>
|
|
161
|
+
{/each}
|
|
162
|
+
</select>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<ul
|
|
167
|
+
bind:this={listEl}
|
|
168
|
+
id={listId}
|
|
169
|
+
role="listbox"
|
|
170
|
+
aria-label="Fonts"
|
|
171
|
+
style="list-style:none;margin:0;padding:2px;max-height:var(--pixerate-font-picker-list-height, 180px);overflow-y:auto;border-radius:6px;border:1px solid var(--pixerate-font-picker-border, #3a4152);"
|
|
172
|
+
>
|
|
173
|
+
{#if shown.length === 0}
|
|
174
|
+
<li role="presentation" style="padding:6px 8px;opacity:0.7;">{s.loading ? 'Loading…' : 'No fonts match'}</li>
|
|
175
|
+
{/if}
|
|
176
|
+
{#each shown as font, i (font.family)}
|
|
177
|
+
{@const selected = font.family.toLowerCase() === s.value.family.toLowerCase()}
|
|
178
|
+
{@const highlighted = i === s.highlighted}
|
|
179
|
+
<li
|
|
180
|
+
id={optionId(i)}
|
|
181
|
+
role="option"
|
|
182
|
+
tabindex="-1"
|
|
183
|
+
aria-selected={selected}
|
|
184
|
+
data-highlighted={highlighted || undefined}
|
|
185
|
+
onmousedown={(e) => e.preventDefault()}
|
|
186
|
+
onmouseenter={() => model.setHighlight(i)}
|
|
187
|
+
onclick={() => model.selectFamily(font.family)}
|
|
188
|
+
onkeydown={() => {}}
|
|
189
|
+
style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:5px 8px;border-radius:4px;cursor:pointer;background:{selected
|
|
190
|
+
? 'var(--pixerate-font-picker-selected, #2f3b57)'
|
|
191
|
+
: highlighted
|
|
192
|
+
? 'var(--pixerate-font-picker-hover, #2a3040)'
|
|
193
|
+
: 'transparent'};"
|
|
194
|
+
>
|
|
195
|
+
<span style="font-family:{model.familyCss(font)};font-size:14px;">{font.family}</span>
|
|
196
|
+
<span style="opacity:0.6;font-size:10px;">{FONT_CATEGORY_LABELS[font.category]}</span>
|
|
197
|
+
</li>
|
|
198
|
+
{/each}
|
|
199
|
+
</ul>
|
|
200
|
+
|
|
201
|
+
{#if showWeight}
|
|
202
|
+
<div style="display:flex;gap:6px;">
|
|
203
|
+
<select
|
|
204
|
+
aria-label="Font weight"
|
|
205
|
+
value={s.value.weight}
|
|
206
|
+
{disabled}
|
|
207
|
+
onchange={(e) => model.setWeight(Number((e.currentTarget as HTMLSelectElement).value))}
|
|
208
|
+
style={field}
|
|
209
|
+
>
|
|
210
|
+
{#each weights as w (w)}
|
|
211
|
+
<option value={w}>{w}</option>
|
|
212
|
+
{/each}
|
|
213
|
+
</select>
|
|
214
|
+
<select
|
|
215
|
+
aria-label="Font style"
|
|
216
|
+
value={s.value.style}
|
|
217
|
+
{disabled}
|
|
218
|
+
onchange={(e) => model.setStyle((e.currentTarget as HTMLSelectElement).value as FontStyle)}
|
|
219
|
+
style={field}
|
|
220
|
+
>
|
|
221
|
+
{#each styles as st (st)}
|
|
222
|
+
<option value={st}>{st}</option>
|
|
223
|
+
{/each}
|
|
224
|
+
</select>
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
{@render children?.()}
|
|
229
|
+
|
|
230
|
+
{#if showPreview}
|
|
231
|
+
<div
|
|
232
|
+
aria-label="Font preview"
|
|
233
|
+
style="font-family:{css.fontFamily};font-weight:{css.fontWeight};font-style:{css.fontStyle};font-size:{Math.min(
|
|
234
|
+
s.value.size,
|
|
235
|
+
48
|
|
236
|
+
)}px;line-height:{css.lineHeight};letter-spacing:{css.letterSpacing};padding:6px 4px;overflow-wrap:anywhere;"
|
|
237
|
+
>
|
|
238
|
+
{s.previewText}
|
|
239
|
+
</div>
|
|
240
|
+
{/if}
|
|
241
|
+
|
|
242
|
+
<div role="status" aria-live="polite" style="opacity:0.75;">{status}</div>
|
|
243
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import { FontPickerModel, type FontProvider, type FontSpec, type FontSummary } from '@pixerate/editor/typography';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** The selected font (controlled). */
|
|
5
|
+
value?: Partial<FontSpec>;
|
|
6
|
+
/** Called when the user (or an agent through the model's tools) picks a different font, weight or style. */
|
|
7
|
+
onchange?: (value: FontSpec) => void;
|
|
8
|
+
onChange?: (value: FontSpec) => void;
|
|
9
|
+
/** An existing model; when given, `provider`, `fonts` and `previewText` are ignored. */
|
|
10
|
+
model?: FontPickerModel;
|
|
11
|
+
/** Where fonts come from (fetched on mount). */
|
|
12
|
+
provider?: FontProvider;
|
|
13
|
+
/** A fixed list of fonts. */
|
|
14
|
+
fonts?: readonly FontSummary[];
|
|
15
|
+
/** Text for the live preview. */
|
|
16
|
+
previewText?: string;
|
|
17
|
+
/** Show the live preview (default true). */
|
|
18
|
+
showPreview?: boolean;
|
|
19
|
+
/** Show the category filter (default true). */
|
|
20
|
+
showCategories?: boolean;
|
|
21
|
+
/** Show the weight and style selects (default true). */
|
|
22
|
+
showWeight?: boolean;
|
|
23
|
+
/** Most options rendered at once (default 200); search to reach the rest. */
|
|
24
|
+
maxResults?: number;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
class?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
/** Accessible label for the group (default "Font picker"). */
|
|
30
|
+
'aria-label'?: string;
|
|
31
|
+
/** Extra controls rendered above the preview (used by `TypographyControl`). */
|
|
32
|
+
children?: Snippet;
|
|
33
|
+
}
|
|
34
|
+
declare const FontPicker: import("svelte").Component<Props, {}, "">;
|
|
35
|
+
type FontPicker = ReturnType<typeof FontPicker>;
|
|
36
|
+
export default FontPicker;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy, untrack } from 'svelte';
|
|
3
|
+
import { FontPickerModel, type FontProvider, type FontSpec, type FontSummary } from '@pixerate/editor/typography';
|
|
4
|
+
import FontPicker from './FontPicker.svelte';
|
|
5
|
+
import { fontPickerOf } from './picker.svelte.js';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** The selected font and metrics (controlled). */
|
|
9
|
+
value?: Partial<FontSpec>;
|
|
10
|
+
/** Called with the whole spec when anything changes in the control (or through its agent tools). */
|
|
11
|
+
onchange?: (value: FontSpec) => void;
|
|
12
|
+
onChange?: (value: FontSpec) => void;
|
|
13
|
+
/** An existing model; when given, `provider`, `fonts` and `previewText` are ignored. */
|
|
14
|
+
model?: FontPickerModel;
|
|
15
|
+
provider?: FontProvider;
|
|
16
|
+
fonts?: readonly FontSummary[];
|
|
17
|
+
previewText?: string;
|
|
18
|
+
showPreview?: boolean;
|
|
19
|
+
showCategories?: boolean;
|
|
20
|
+
showWeight?: boolean;
|
|
21
|
+
maxResults?: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
class?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Accessible label for the group (default "Typography"). */
|
|
27
|
+
'aria-label'?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
value,
|
|
32
|
+
onchange,
|
|
33
|
+
onChange,
|
|
34
|
+
model: external,
|
|
35
|
+
provider,
|
|
36
|
+
fonts,
|
|
37
|
+
previewText,
|
|
38
|
+
disabled = false,
|
|
39
|
+
'aria-label': ariaLabel = 'Typography',
|
|
40
|
+
...rest
|
|
41
|
+
}: Props = $props();
|
|
42
|
+
|
|
43
|
+
const model = untrack(() => external ?? new FontPickerModel({ provider, fonts, value, previewText }));
|
|
44
|
+
const picker = fontPickerOf(model);
|
|
45
|
+
onDestroy(() => picker.destroy());
|
|
46
|
+
if (!untrack(() => external) && untrack(() => provider)) void model.refresh();
|
|
47
|
+
const v = $derived(picker.state.value);
|
|
48
|
+
|
|
49
|
+
const numberField =
|
|
50
|
+
'box-sizing:border-box;width:100%;padding:5px 6px;border-radius:6px;border:1px solid var(--pixerate-font-picker-border, #3a4152);background:var(--pixerate-font-picker-field-bg, #151922);color:inherit;font:inherit;';
|
|
51
|
+
|
|
52
|
+
function commit(e: Event, set: (n: number) => void) {
|
|
53
|
+
const n = (e.currentTarget as HTMLInputElement).valueAsNumber;
|
|
54
|
+
if (Number.isFinite(n)) set(n);
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<FontPicker {model} {value} onchange={(next) => (onchange ?? onChange)?.(next)} {disabled} aria-label={ariaLabel} {...rest}>
|
|
59
|
+
<div style="display:flex;gap:6px;">
|
|
60
|
+
<label style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;">
|
|
61
|
+
<span style="opacity:0.75;">Font size <span style="opacity:0.6;">px</span></span>
|
|
62
|
+
<input type="number" aria-label="Font size" value={v.size} step="1" min="1" max="1000" {disabled} oninput={(e) => commit(e, (n) => model.setSize(n))} style={numberField} />
|
|
63
|
+
</label>
|
|
64
|
+
<label style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;">
|
|
65
|
+
<span style="opacity:0.75;">Line height <span style="opacity:0.6;">×</span></span>
|
|
66
|
+
<input type="number" aria-label="Line height" value={v.lineHeight} step="0.05" min="0.5" max="4" {disabled} oninput={(e) => commit(e, (n) => model.setLineHeight(n))} style={numberField} />
|
|
67
|
+
</label>
|
|
68
|
+
<label style="display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;">
|
|
69
|
+
<span style="opacity:0.75;">Letter spacing <span style="opacity:0.6;">em</span></span>
|
|
70
|
+
<input type="number" aria-label="Letter spacing" value={v.letterSpacing} step="0.01" min="-0.5" max="1" {disabled} oninput={(e) => commit(e, (n) => model.setLetterSpacing(n))} style={numberField} />
|
|
71
|
+
</label>
|
|
72
|
+
</div>
|
|
73
|
+
</FontPicker>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FontPickerModel, type FontProvider, type FontSpec, type FontSummary } from '@pixerate/editor/typography';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The selected font and metrics (controlled). */
|
|
4
|
+
value?: Partial<FontSpec>;
|
|
5
|
+
/** Called with the whole spec when anything changes in the control (or through its agent tools). */
|
|
6
|
+
onchange?: (value: FontSpec) => void;
|
|
7
|
+
onChange?: (value: FontSpec) => void;
|
|
8
|
+
/** An existing model; when given, `provider`, `fonts` and `previewText` are ignored. */
|
|
9
|
+
model?: FontPickerModel;
|
|
10
|
+
provider?: FontProvider;
|
|
11
|
+
fonts?: readonly FontSummary[];
|
|
12
|
+
previewText?: string;
|
|
13
|
+
showPreview?: boolean;
|
|
14
|
+
showCategories?: boolean;
|
|
15
|
+
showWeight?: boolean;
|
|
16
|
+
maxResults?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
class?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Accessible label for the group (default "Typography"). */
|
|
22
|
+
'aria-label'?: string;
|
|
23
|
+
}
|
|
24
|
+
declare const TypographyControl: import("svelte").Component<Props, {}, "">;
|
|
25
|
+
type TypographyControl = ReturnType<typeof TypographyControl>;
|
|
26
|
+
export default TypographyControl;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import type FontPickerComponent from './FontPicker.svelte';
|
|
3
|
+
import type TypographyControlComponent from './TypographyControl.svelte';
|
|
4
|
+
export { default as FontPicker } from './FontPicker.svelte';
|
|
5
|
+
export { default as TypographyControl } from './TypographyControl.svelte';
|
|
6
|
+
export type FontPickerProps = ComponentProps<typeof FontPickerComponent>;
|
|
7
|
+
export type TypographyControlProps = ComponentProps<typeof TypographyControlComponent>;
|
|
8
|
+
export { fontPickerOf, type FontPickerBinding } from './picker.svelte.js';
|
|
9
|
+
export * from '@pixerate/editor/typography';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as FontPicker } from './FontPicker.svelte';
|
|
2
|
+
export { default as TypographyControl } from './TypographyControl.svelte';
|
|
3
|
+
export { fontPickerOf } from './picker.svelte.js';
|
|
4
|
+
// Re-export the core typography module for consumer convenience
|
|
5
|
+
export * from '@pixerate/editor/typography';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FontPickerModel, FontPickerState } from '@pixerate/editor/typography';
|
|
2
|
+
export interface FontPickerBinding {
|
|
3
|
+
/** The model's state, reactive. */
|
|
4
|
+
readonly state: FontPickerState;
|
|
5
|
+
readonly model: FontPickerModel;
|
|
6
|
+
/** Stops following the model. */
|
|
7
|
+
destroy(): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Follows a {@link FontPickerModel} with Svelte 5 runes: `binding.state` updates whenever the model
|
|
11
|
+
* changes (from the UI or from its agent tools). Call `destroy()` when the component unmounts.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fontPickerOf(model: FontPickerModel): FontPickerBinding;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follows a {@link FontPickerModel} with Svelte 5 runes: `binding.state` updates whenever the model
|
|
3
|
+
* changes (from the UI or from its agent tools). Call `destroy()` when the component unmounts.
|
|
4
|
+
*/
|
|
5
|
+
export function fontPickerOf(model) {
|
|
6
|
+
let current = $state.raw(model.getState());
|
|
7
|
+
const unsubscribe = model.subscribe((next) => {
|
|
8
|
+
current = next;
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
get state() {
|
|
12
|
+
return current;
|
|
13
|
+
},
|
|
14
|
+
model,
|
|
15
|
+
destroy: () => {
|
|
16
|
+
unsubscribe();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
videoTransitionAudioCurve,
|
|
26
26
|
videoKeyframeToggleEdit,
|
|
27
27
|
videoKeyframeValueEdit,
|
|
28
|
+
videoClipHeading,
|
|
29
|
+
videoMaskEdit,
|
|
30
|
+
videoSkinProtectionEdits,
|
|
31
|
+
VIDEO_ADJUSTMENT_SCOPES,
|
|
32
|
+
VIDEO_ADJUSTMENT_SCOPE_LABELS,
|
|
33
|
+
VIDEO_MASK_SHAPES,
|
|
34
|
+
VIDEO_MASK_SHAPE_LABELS,
|
|
35
|
+
type VideoAdjustmentScope,
|
|
36
|
+
type VideoClipMask,
|
|
37
|
+
type VideoMaskShape,
|
|
28
38
|
type VideoEdit,
|
|
29
39
|
type VideoEditorController,
|
|
30
40
|
type VideoFadeCurve,
|
|
@@ -166,6 +176,46 @@
|
|
|
166
176
|
// A strength of 0 changes nothing, so it takes the LUT off.
|
|
167
177
|
setLut(n > 0 ? { ...lut, strength: Math.min(1, n) } : null, n > 0 ? 'Change LUT strength' : 'Remove LUT');
|
|
168
178
|
}
|
|
179
|
+
function skinProtection(raw: string) {
|
|
180
|
+
const n = Number(raw);
|
|
181
|
+
if (!clip || !raw.trim() || !Number.isFinite(n)) return;
|
|
182
|
+
const r = controller.apply(videoSkinProtectionEdits(editor.project, [clip.id], n), n > 0 ? 'Protect skin tones' : 'Stop protecting skin tones');
|
|
183
|
+
if (!r.ok) localError = { text: r.error, revision: editor.revision };
|
|
184
|
+
}
|
|
185
|
+
function setScope(scope: VideoAdjustmentScope) {
|
|
186
|
+
if (!clip || !at) return;
|
|
187
|
+
const target = editor.project.tracks.find((t) => t.kind === 'video' && t.id !== at.track.id)?.id;
|
|
188
|
+
if (scope === 'track') apply({ op: 'update_clip', clip: clip.id, set: { scope, targetTrack: (clip.type === 'adjustment' ? clip.targetTrack : undefined) ?? target ?? at.track.id } }, 'Change adjustment scope');
|
|
189
|
+
else apply({ op: 'update_clip', clip: clip.id, ...(scope === 'underneath' ? { remove: ['scope', 'targetTrack'] } : { set: { scope }, remove: ['targetTrack'] }) }, 'Change adjustment scope');
|
|
190
|
+
}
|
|
191
|
+
function setMaskShape(shape: VideoMaskShape | '') {
|
|
192
|
+
if (!clip) return;
|
|
193
|
+
const { points: _p, cornerRadius: _c, ...rest } = clip.mask ?? ({ shape: 'ellipse' } as VideoClipMask);
|
|
194
|
+
const points = [
|
|
195
|
+
{ x: 0.5, y: 0.2 },
|
|
196
|
+
{ x: 0.8, y: 0.8 },
|
|
197
|
+
{ x: 0.2, y: 0.8 }
|
|
198
|
+
];
|
|
199
|
+
apply(videoMaskEdit(clip.id, shape ? { ...rest, shape, ...(shape === 'polygon' ? { points } : {}) } : null), shape ? 'Mask clip' : 'Remove mask');
|
|
200
|
+
}
|
|
201
|
+
function setMaskValue(key: 'width' | 'height' | 'feather' | 'rotation', raw: string) {
|
|
202
|
+
if (!clip?.mask) return;
|
|
203
|
+
const n = Number(raw);
|
|
204
|
+
if (raw.trim() && !Number.isFinite(n)) return;
|
|
205
|
+
const { [key]: _old, ...rest } = clip.mask;
|
|
206
|
+
apply(videoMaskEdit(clip.id, raw.trim() ? { ...rest, [key]: n } : rest), 'Change mask');
|
|
207
|
+
}
|
|
208
|
+
function invertMask(on: boolean) {
|
|
209
|
+
if (!clip?.mask) return;
|
|
210
|
+
const { inverted: _i, ...rest } = clip.mask;
|
|
211
|
+
apply(videoMaskEdit(clip.id, on ? { ...rest, inverted: true } : rest), 'Invert mask');
|
|
212
|
+
}
|
|
213
|
+
const MASK_FIELDS = [
|
|
214
|
+
{ key: 'width', label: 'Mask width', step: 0.05, placeholder: '0.5' },
|
|
215
|
+
{ key: 'height', label: 'Mask height', step: 0.05, placeholder: '0.5' },
|
|
216
|
+
{ key: 'feather', label: 'Feather (px)', step: 5, placeholder: '0' },
|
|
217
|
+
{ key: 'rotation', label: 'Mask angle', step: 5, placeholder: '0' }
|
|
218
|
+
] as const;
|
|
169
219
|
function transitionAudioCurve(curve: string) {
|
|
170
220
|
if (!clip?.transition) return;
|
|
171
221
|
const { audioCurve: _, ...rest } = clip.transition;
|
|
@@ -198,9 +248,8 @@
|
|
|
198
248
|
</p>
|
|
199
249
|
{:else}
|
|
200
250
|
<div class="flex items-center gap-1">
|
|
201
|
-
<span class="font-medium">
|
|
251
|
+
<span class="font-medium">{videoClipHeading(clip)}</span>
|
|
202
252
|
<code class="text-[10px] text-muted-foreground">{clip.id}</code>
|
|
203
|
-
<span class="text-[10px] text-muted-foreground">{clip.type}</span>
|
|
204
253
|
{#if at?.track.locked}<span class="ml-auto text-[10px] text-amber-500">Track {at.track.name ?? at.track.id} is locked</span>{/if}
|
|
205
254
|
</div>
|
|
206
255
|
<div class="grid grid-cols-[5.5rem_1fr] items-center gap-x-2 gap-y-1">
|
|
@@ -244,6 +293,41 @@
|
|
|
244
293
|
{#if clip.grade?.lut}
|
|
245
294
|
<span class="text-muted-foreground">LUT strength</span>
|
|
246
295
|
<input class={input} type="number" min="0" max="1" step="0.05" aria-label="LUT strength" {disabled} value={clip.grade.lut.strength ?? 1} onchange={(e) => lutStrength(e.currentTarget.value)} data-field="grade.lut.strength" />
|
|
296
|
+
<span class="text-muted-foreground">Protect skin</span>
|
|
297
|
+
<input class={input} type="number" min="0" max="1" step="0.1" aria-label="Protect skin tones" title="How much of the LUT to hold back on skin tones (0 to 1)" {disabled} value={clip.grade.lut.preserveSkinTones ?? 0} onchange={(e) => skinProtection(e.currentTarget.value)} data-field="grade.lut.preserveSkinTones" />
|
|
298
|
+
{/if}
|
|
299
|
+
{/if}
|
|
300
|
+
{#if clip.type === 'adjustment'}
|
|
301
|
+
<span class="text-muted-foreground">Changes</span>
|
|
302
|
+
<select class={input} aria-label="Adjustment scope" {disabled} value={clip.scope ?? 'underneath'} onchange={(e) => setScope(e.currentTarget.value as VideoAdjustmentScope)} data-field="scope">
|
|
303
|
+
{#each VIDEO_ADJUSTMENT_SCOPES as s (s)}
|
|
304
|
+
<option value={s}>{VIDEO_ADJUSTMENT_SCOPE_LABELS[s]}</option>
|
|
305
|
+
{/each}
|
|
306
|
+
</select>
|
|
307
|
+
{#if clip.scope === 'track'}
|
|
308
|
+
<span class="text-muted-foreground">Track</span>
|
|
309
|
+
<select class={input} aria-label="Adjusted track" {disabled} value={clip.targetTrack ?? ''} onchange={(e) => set('targetTrack', e.currentTarget.value, 'Change adjusted track')} data-field="targetTrack">
|
|
310
|
+
{#each editor.project.tracks.filter((t) => t.kind === 'video') as t (t.id)}
|
|
311
|
+
<option value={t.id}>{t.name ?? t.id}</option>
|
|
312
|
+
{/each}
|
|
313
|
+
</select>
|
|
314
|
+
{/if}
|
|
315
|
+
{/if}
|
|
316
|
+
{#if at?.track.kind === 'video'}
|
|
317
|
+
<span class="text-muted-foreground">Mask</span>
|
|
318
|
+
<select class={input} aria-label="Mask shape" {disabled} value={clip.mask?.shape ?? ''} onchange={(e) => setMaskShape(e.currentTarget.value as VideoMaskShape | '')} data-field="mask">
|
|
319
|
+
<option value="">none</option>
|
|
320
|
+
{#each VIDEO_MASK_SHAPES as s (s)}
|
|
321
|
+
<option value={s}>{VIDEO_MASK_SHAPE_LABELS[s]}</option>
|
|
322
|
+
{/each}
|
|
323
|
+
</select>
|
|
324
|
+
{#if clip.mask}
|
|
325
|
+
{#each MASK_FIELDS as f (f.key)}
|
|
326
|
+
<span class="text-muted-foreground">{f.label}</span>
|
|
327
|
+
<input class={input} type="number" step={f.step} min={f.key === 'rotation' ? undefined : 0} placeholder={f.placeholder} aria-label={f.label} {disabled} value={clip.mask[f.key] ?? ''} onchange={(e) => setMaskValue(f.key, e.currentTarget.value)} data-field="mask.{f.key}" />
|
|
328
|
+
{/each}
|
|
329
|
+
<span class="text-muted-foreground">Invert mask</span>
|
|
330
|
+
<input type="checkbox" class="justify-self-start" aria-label="Invert mask" {disabled} checked={!!clip.mask.inverted} onchange={(e) => invertMask(e.currentTarget.checked)} data-field="mask.inverted" />
|
|
247
331
|
{/if}
|
|
248
332
|
{/if}
|
|
249
333
|
{#if at?.track.kind === 'video'}
|
|
@@ -325,7 +409,7 @@
|
|
|
325
409
|
{:else if clip.type === 'effect'}
|
|
326
410
|
<span class="text-muted-foreground">Effect</span>
|
|
327
411
|
<span class="truncate" title="Change its controls in the Effects panel" data-field="effect">{clip.name ?? clip.effect}</span>
|
|
328
|
-
{:else}
|
|
412
|
+
{:else if clip.type === 'color'}
|
|
329
413
|
<span class="text-muted-foreground">Colour</span>
|
|
330
414
|
<input class={input} aria-label="Clip colour" {disabled} value={clip.color} onchange={(e) => text('color', e.currentTarget.value, 'Change colour', true)} data-field="color" />
|
|
331
415
|
{/if}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { describeVideoEditPlan, formatVideoSeconds, type VideoEditorController } from '@pixerate/editor/video-editor';
|
|
3
|
+
import { videoEditorStateOf } from './createVideoEditor.svelte.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
controller: VideoEditorController;
|
|
7
|
+
/** Allow accepting plans (default true; discarding is always allowed). */
|
|
8
|
+
editable?: boolean;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { controller, editable = true, class: className = '' }: Props = $props();
|
|
13
|
+
const view = videoEditorStateOf(() => controller);
|
|
14
|
+
const editor = $derived(view.current);
|
|
15
|
+
const total = $derived(Math.max(1, editor.duration));
|
|
16
|
+
let message = $state<string | null>(null);
|
|
17
|
+
const btn = 'rounded border border-border px-2 py-0.5 text-[11px] disabled:opacity-40';
|
|
18
|
+
|
|
19
|
+
function accept(id: string) {
|
|
20
|
+
const r = controller.applyPlan(id);
|
|
21
|
+
message = r.ok ? null : r.error;
|
|
22
|
+
}
|
|
23
|
+
function discard(id: string) {
|
|
24
|
+
controller.discardPlan(id);
|
|
25
|
+
message = null;
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<!--
|
|
30
|
+
Proposed edit plans waiting for review: title, what each would do (length change, clips added,
|
|
31
|
+
changed and removed, new problems), a strip of the stretches it cuts (click to go there) and
|
|
32
|
+
Accept / Discard. Accepting applies the plan as one undo step. Renders nothing without plans.
|
|
33
|
+
-->
|
|
34
|
+
{#if editor.plans.length}
|
|
35
|
+
<section class="flex flex-col gap-2 rounded-md border border-amber-500/50 bg-amber-500/5 p-2 text-xs {className}" aria-label="Proposed edits" data-edit-plans="">
|
|
36
|
+
{#each editor.plans as plan (plan.id)}
|
|
37
|
+
{@const s = plan.summary}
|
|
38
|
+
{@const stale = editor.revision >= 0 && controller.isPlanStale(plan.id)}
|
|
39
|
+
<article class="flex flex-col gap-1" data-edit-plan={plan.id}>
|
|
40
|
+
<div class="flex items-center gap-2">
|
|
41
|
+
<h3 class="flex-1 font-semibold">{plan.title}</h3>
|
|
42
|
+
<span class="tabular-nums {s.durationDelta < 0 ? 'text-emerald-600' : 'text-muted-foreground'}" data-plan-delta="">
|
|
43
|
+
{s.durationDelta < 0 ? '−' : s.durationDelta > 0 ? '+' : '±'}{formatVideoSeconds(Math.abs(s.durationDelta))}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
{#if plan.description}<p class="text-muted-foreground">{plan.description}</p>{/if}
|
|
47
|
+
<p class="text-muted-foreground" data-plan-summary="">{describeVideoEditPlan(plan)} · {formatVideoSeconds(s.durationBefore)} → {formatVideoSeconds(s.durationAfter)}</p>
|
|
48
|
+
{#if plan.ranges?.length}
|
|
49
|
+
<div class="relative h-3 w-full overflow-hidden rounded bg-muted" aria-label="Stretches the plan cuts" data-plan-strip="">
|
|
50
|
+
{#each plan.ranges as r, i (i)}
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class="absolute top-0 h-full bg-rose-500/70 hover:bg-rose-600"
|
|
54
|
+
style="left: {(r.from / total) * 100}%; width: {Math.max(0.4, ((r.to - r.from) / total) * 100)}%"
|
|
55
|
+
title={`${r.reason ? `${r.reason}: ` : ''}${r.text ?? ''} (${formatVideoSeconds(r.from)})`}
|
|
56
|
+
aria-label={`Go to ${r.reason ?? 'cut'} at ${formatVideoSeconds(r.from)}`}
|
|
57
|
+
onclick={() => controller.seek(r.from)}
|
|
58
|
+
></button>
|
|
59
|
+
{/each}
|
|
60
|
+
</div>
|
|
61
|
+
{/if}
|
|
62
|
+
{#if s.newIssues.length}<p class="text-amber-600">It would add {s.newIssues.length} problem{s.newIssues.length > 1 ? 's' : ''}: {s.newIssues[0].message}</p>{/if}
|
|
63
|
+
{#if stale}<p class="text-amber-600" data-plan-stale="">The project changed since this plan was made; it is checked again when accepted.</p>{/if}
|
|
64
|
+
<div class="flex gap-1">
|
|
65
|
+
<button type="button" class={btn} disabled={!editable || editor.readOnly} onclick={() => accept(plan.id)} data-accept-plan="">Accept plan</button>
|
|
66
|
+
<button type="button" class={btn} onclick={() => discard(plan.id)} data-discard-plan="">Discard</button>
|
|
67
|
+
</div>
|
|
68
|
+
</article>
|
|
69
|
+
{/each}
|
|
70
|
+
{#if message}<p class="text-red-500" role="alert" data-plan-error="">{message}</p>{/if}
|
|
71
|
+
</section>
|
|
72
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type VideoEditorController } from '@pixerate/editor/video-editor';
|
|
2
|
+
interface Props {
|
|
3
|
+
controller: VideoEditorController;
|
|
4
|
+
/** Allow accepting plans (default true; discarding is always allowed). */
|
|
5
|
+
editable?: boolean;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
declare const EditPlanPreview: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type EditPlanPreview = ReturnType<typeof EditPlanPreview>;
|
|
10
|
+
export default EditPlanPreview;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { VideoDisfluencyKind, VideoEditorController } from '@pixerate/editor/video-editor';
|
|
3
|
+
import { transcriptVideoOf } from './createVideoEditor.svelte.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
controller: VideoEditorController;
|
|
7
|
+
/** Track whose words to show (default: the first audio track with a transcribed clip, else the first video track with one). */
|
|
8
|
+
track?: string;
|
|
9
|
+
/** Deleting words proposes a plan for review instead of cutting at once (default false). */
|
|
10
|
+
planFirst?: boolean;
|
|
11
|
+
/** Allow cutting by text (default true). */
|
|
12
|
+
editable?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { controller, track, planFirst = false, editable = true, class: className = '' }: Props = $props();
|
|
17
|
+
const view = transcriptVideoOf(() => controller, () => ({ track, planFirst }));
|
|
18
|
+
const ts = $derived(view.current);
|
|
19
|
+
const canEdit = $derived(editable && ts.editable);
|
|
20
|
+
let anchor = $state<number | null>(null);
|
|
21
|
+
const btn = 'rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40';
|
|
22
|
+
const CHIP: Record<VideoDisfluencyKind, string> = {
|
|
23
|
+
filler: 'bg-amber-400/30 text-amber-700 dark:text-amber-300',
|
|
24
|
+
pause: 'bg-sky-400/25 text-sky-700 dark:text-sky-300',
|
|
25
|
+
repetition: 'bg-violet-400/25 text-violet-700 dark:text-violet-300'
|
|
26
|
+
};
|
|
27
|
+
const KIND_LABEL: Record<VideoDisfluencyKind, string> = { filler: 'filler', pause: 'pause', repetition: 'repeat' };
|
|
28
|
+
|
|
29
|
+
function pick(e: MouseEvent, i: number) {
|
|
30
|
+
const t = view.transcript;
|
|
31
|
+
if (e.shiftKey && anchor !== null) t.select(anchor, i);
|
|
32
|
+
else {
|
|
33
|
+
anchor = i;
|
|
34
|
+
t.select(i);
|
|
35
|
+
t.seekToWord(i);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function onKey(e: KeyboardEvent) {
|
|
39
|
+
if (canEdit && ts.selection && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
view.transcript.deleteSelection();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<!--
|
|
47
|
+
Edit by text, with disfluencies marked: the word under the playhead highlighted as it plays,
|
|
48
|
+
fillers, long pauses and stutters marked and listed as chips that cut each one out with a click.
|
|
49
|
+
Click a word to go to it, Shift-click to select a run, Delete to cut it (or propose the cut with
|
|
50
|
+
planFirst). "Plan cleanup" proposes every disfluency as one plan for review (see EditPlanPreview).
|
|
51
|
+
-->
|
|
52
|
+
{#if !ts.words.length}
|
|
53
|
+
<p class="text-xs text-muted-foreground {className}" data-transcript-editor="" data-transcript-empty="">
|
|
54
|
+
No transcribed media on the timeline. Attach a transcript (Whisper JSON, .srt or .vtt) to edit by text.
|
|
55
|
+
</p>
|
|
56
|
+
{:else}
|
|
57
|
+
<div class="flex flex-col gap-2 text-xs {className}" data-transcript-editor="">
|
|
58
|
+
<div class="max-h-56 overflow-y-auto leading-6" role="listbox" aria-label="Transcript" aria-multiselectable="true" tabindex="0" onkeydown={onKey}>
|
|
59
|
+
{#each ts.words as w, i (`${w.clip}:${w.index}`)}
|
|
60
|
+
{@const selected = !!ts.selection && i >= ts.selection[0] && i <= ts.selection[1]}
|
|
61
|
+
{@const kinds = ts.flags[i]}
|
|
62
|
+
<span
|
|
63
|
+
role="option"
|
|
64
|
+
aria-selected={selected}
|
|
65
|
+
tabindex="-1"
|
|
66
|
+
class="cursor-pointer rounded px-0.5 {selected ? 'bg-rose-500/30' : i === ts.current ? 'bg-amber-400/50' : kinds?.length ? CHIP[kinds[0]] : 'hover:bg-muted'}"
|
|
67
|
+
data-word={i}
|
|
68
|
+
data-current={i === ts.current ? '' : undefined}
|
|
69
|
+
data-selected={selected ? '' : undefined}
|
|
70
|
+
data-disfluency={kinds?.join(' ') || undefined}
|
|
71
|
+
title={kinds?.length ? kinds.map((k) => KIND_LABEL[k]).join(', ') : undefined}
|
|
72
|
+
onclick={(e) => pick(e, i)}
|
|
73
|
+
onkeydown={() => {}}>{w.word}</span
|
|
74
|
+
>{#if kinds?.includes('pause')}<span class="mx-0.5 text-sky-500" aria-hidden="true">⏸</span>{:else}{' '}{/if}
|
|
75
|
+
{/each}
|
|
76
|
+
</div>
|
|
77
|
+
{#if canEdit}
|
|
78
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
79
|
+
<button type="button" class={btn} disabled={!ts.selection} onclick={() => view.transcript.deleteSelection()} data-delete-words="">
|
|
80
|
+
{planFirst ? 'Propose deleting words' : 'Delete words'}
|
|
81
|
+
</button>
|
|
82
|
+
<button type="button" class={btn} disabled={!ts.disfluencies.length} onclick={() => view.transcript.planCleanup()} data-plan-cleanup="">
|
|
83
|
+
Plan cleanup ({ts.disfluencies.length})
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
{/if}
|
|
87
|
+
{#if ts.disfluencies.length}
|
|
88
|
+
<ul class="flex flex-wrap gap-1" aria-label="Disfluencies" data-disfluencies="">
|
|
89
|
+
{#each ts.disfluencies as d, i (`${d.kind}:${d.from}`)}
|
|
90
|
+
<li class="flex items-center gap-1 rounded px-1 {CHIP[d.kind]}" data-disfluency-chip={d.kind}>
|
|
91
|
+
<button type="button" class="tabular-nums" title="Go to it" onclick={() => controller.seek(d.from)}>
|
|
92
|
+
{KIND_LABEL[d.kind]}: {d.kind === 'pause' ? `${((d.to - d.from) / 1000).toFixed(1)} s` : d.text}
|
|
93
|
+
</button>
|
|
94
|
+
{#if canEdit}
|
|
95
|
+
<button type="button" aria-label={`Cut ${KIND_LABEL[d.kind]} ${d.text}`} onclick={() => view.transcript.removeDisfluency(i)} data-cut-disfluency={i}>✕</button>
|
|
96
|
+
{/if}
|
|
97
|
+
</li>
|
|
98
|
+
{/each}
|
|
99
|
+
</ul>
|
|
100
|
+
{/if}
|
|
101
|
+
{#if ts.error}
|
|
102
|
+
<p class="text-red-500" role="alert" data-transcript-error="">{ts.error}</p>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
105
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VideoEditorController } from '@pixerate/editor/video-editor';
|
|
2
|
+
interface Props {
|
|
3
|
+
controller: VideoEditorController;
|
|
4
|
+
/** Track whose words to show (default: the first audio track with a transcribed clip, else the first video track with one). */
|
|
5
|
+
track?: string;
|
|
6
|
+
/** Deleting words proposes a plan for review instead of cutting at once (default false). */
|
|
7
|
+
planFirst?: boolean;
|
|
8
|
+
/** Allow cutting by text (default true). */
|
|
9
|
+
editable?: boolean;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
declare const TranscriptEditor: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type TranscriptEditor = ReturnType<typeof TranscriptEditor>;
|
|
14
|
+
export default TranscriptEditor;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import ClipInspector from './ClipInspector.svelte';
|
|
6
6
|
import MediaBin from './MediaBin.svelte';
|
|
7
7
|
import TranscriptPanel from './TranscriptPanel.svelte';
|
|
8
|
+
import TranscriptEditor from './TranscriptEditor.svelte';
|
|
9
|
+
import EditPlanPreview from './EditPlanPreview.svelte';
|
|
8
10
|
import VideoPreview from './VideoPreview.svelte';
|
|
9
11
|
import VideoScopes from './VideoScopes.svelte';
|
|
10
12
|
import VideoTimeline from './VideoTimeline.svelte';
|
|
@@ -30,6 +32,12 @@
|
|
|
30
32
|
showIssues?: boolean;
|
|
31
33
|
/** Show the transcript panel for editing by text (default true). */
|
|
32
34
|
showTranscript?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* How the transcript is shown: `panel` (default) is `TranscriptPanel`, which also loads
|
|
37
|
+
* transcript files; `editor` marks fillers, pauses and stutters and plans cleanups for review
|
|
38
|
+
* (`TranscriptEditor`).
|
|
39
|
+
*/
|
|
40
|
+
transcriptMode?: 'editor' | 'panel';
|
|
33
41
|
/** Show colour scopes (histogram, luma waveform, vectorscope) of the preview under it (default false). */
|
|
34
42
|
showScopes?: boolean;
|
|
35
43
|
/**
|
|
@@ -47,7 +55,7 @@
|
|
|
47
55
|
onplaybackreveal?: () => void | Promise<void>;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
let { controller, options, editable = true, pxPerSecond = $bindable(100), showIssues = true, showTranscript = true, showScopes = false, effects, showEffects, class: className = '', playback, onplaybackreveal }: Props = $props();
|
|
58
|
+
let { controller, options, editable = true, pxPerSecond = $bindable(100), showIssues = true, showTranscript = true, transcriptMode = 'panel', showScopes = false, effects, showEffects, class: className = '', playback, onplaybackreveal }: Props = $props();
|
|
51
59
|
let owned: VideoEditorController | null = null;
|
|
52
60
|
// A controller is made only when none is passed, and kept for the component's lifetime.
|
|
53
61
|
const ctl = $derived(controller ?? (owned ??= new VideoEditorController(options)));
|
|
@@ -55,7 +63,7 @@
|
|
|
55
63
|
const view = videoEditorStateOf(() => ctl);
|
|
56
64
|
const editor = $derived(view.current);
|
|
57
65
|
useEffects(() => ctl, () => effects);
|
|
58
|
-
const effectsShown = $derived(showEffects ?? !!(editor.effectsProvider || editor.project.tracks.some((t) => t.clips.some((c) => c.type === 'effect' || c.effects?.length || c.transition?.type === 'effect'))));
|
|
66
|
+
const effectsShown = $derived(showEffects ?? !!(editor.effectsProvider || editor.project.tracks.some((t) => t.clips.some((c) => c.type === 'effect' || c.type === 'adjustment' || c.effects?.length || c.transition?.type === 'effect'))));
|
|
59
67
|
</script>
|
|
60
68
|
|
|
61
69
|
{#snippet issueList()}
|
|
@@ -79,7 +87,8 @@
|
|
|
79
87
|
<VideoTransport controller={ctl} editable={false} />
|
|
80
88
|
{#if showScopes}<VideoScopes controller={ctl} />{/if}
|
|
81
89
|
<VideoTimeline controller={ctl} bind:pxPerSecond editable={false} {playback} {onplaybackreveal} />
|
|
82
|
-
|
|
90
|
+
<EditPlanPreview controller={ctl} editable={false} />
|
|
91
|
+
{#if showTranscript}{#if transcriptMode === 'editor'}<TranscriptEditor controller={ctl} editable={false} />{:else}<TranscriptPanel controller={ctl} editable={false} />{/if}{/if}
|
|
83
92
|
{@render issueList()}
|
|
84
93
|
</div>
|
|
85
94
|
{:else}
|
|
@@ -96,7 +105,8 @@
|
|
|
96
105
|
</div>
|
|
97
106
|
<div class="flex min-w-0 flex-col gap-2 lg:col-span-3">
|
|
98
107
|
<VideoTimeline controller={ctl} bind:pxPerSecond {playback} {onplaybackreveal} />
|
|
99
|
-
|
|
108
|
+
<EditPlanPreview controller={ctl} />
|
|
109
|
+
{#if showTranscript}{#if transcriptMode === 'editor'}<TranscriptEditor controller={ctl} />{:else}<TranscriptPanel controller={ctl} />{/if}{/if}
|
|
100
110
|
{@render issueList()}
|
|
101
111
|
</div>
|
|
102
112
|
</div>
|
|
@@ -17,6 +17,12 @@ interface Props {
|
|
|
17
17
|
showIssues?: boolean;
|
|
18
18
|
/** Show the transcript panel for editing by text (default true). */
|
|
19
19
|
showTranscript?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* How the transcript is shown: `panel` (default) is `TranscriptPanel`, which also loads
|
|
22
|
+
* transcript files; `editor` marks fillers, pauses and stutters and plans cleanups for review
|
|
23
|
+
* (`TranscriptEditor`).
|
|
24
|
+
*/
|
|
25
|
+
transcriptMode?: 'editor' | 'panel';
|
|
20
26
|
/** Show colour scopes (histogram, luma waveform, vectorscope) of the preview under it (default false). */
|
|
21
27
|
showScopes?: boolean;
|
|
22
28
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { findVideoClip, isVideoKeyframes, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
|
|
2
|
+
import { findVideoClip, isVideoKeyframes, openVideoClipEffectAuthoring, type VideoClipEffectAuthoring, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
|
|
3
|
+
import { onDestroy } from 'svelte';
|
|
3
4
|
import EffectsBrowser from '../effects/EffectsBrowser.svelte';
|
|
5
|
+
import EffectAuthoringEditor from '../effects/EffectAuthoringEditor.svelte';
|
|
4
6
|
import EffectControls from './EffectControls.svelte';
|
|
5
7
|
import { videoEditorStateOf } from './createVideoEditor.svelte.js';
|
|
6
8
|
|
|
@@ -35,6 +37,25 @@
|
|
|
35
37
|
);
|
|
36
38
|
}
|
|
37
39
|
const merge = (gesture: string | null) => ({ merge: gesture ?? undefined });
|
|
40
|
+
let authoring = $state.raw<VideoClipEffectAuthoring | null>(null);
|
|
41
|
+
function closeAuthoring() {
|
|
42
|
+
authoring?.dispose();
|
|
43
|
+
authoring = null;
|
|
44
|
+
}
|
|
45
|
+
function author(clipId: string, instance: string) {
|
|
46
|
+
closeAuthoring();
|
|
47
|
+
openVideoClipEffectAuthoring(controller, clipId, instance).then(
|
|
48
|
+
(a) => (authoring = a),
|
|
49
|
+
(err: unknown) => (message = err instanceof Error ? err.message : String(err))
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
async function commitAuthoring() {
|
|
53
|
+
if (!authoring) return;
|
|
54
|
+
const r = await authoring.commit();
|
|
55
|
+
message = r.ok ? (r.notes?.join(' ') ?? null) : r.error;
|
|
56
|
+
if (r.ok) closeAuthoring();
|
|
57
|
+
}
|
|
58
|
+
onDestroy(closeAuthoring);
|
|
38
59
|
</script>
|
|
39
60
|
|
|
40
61
|
<!--
|
|
@@ -115,6 +136,7 @@
|
|
|
115
136
|
<button type="button" class="min-w-0 flex-1 truncate text-left" aria-expanded={open === e.id} onclick={() => (open = open === e.id ? null : e.id)}>{label}</button>
|
|
116
137
|
<button type="button" class={btn} disabled={disabled || i === 0} aria-label="Move up" onclick={() => report(controller.updateClipEffect(clip!.id, e.id, { index: i - 1 }))}>↑</button>
|
|
117
138
|
<button type="button" class={btn} disabled={disabled || i === count - 1} aria-label="Move down" onclick={() => report(controller.updateClipEffect(clip!.id, e.id, { index: i + 1 }))}>↓</button>
|
|
139
|
+
<button type="button" class={btn} disabled={disabled || !provider} aria-label="Author {label}" title="Tune and keyframe it in the effect authoring editor" onclick={() => author(clip!.id, e.id)} data-author-effect={e.id}>✎</button>
|
|
118
140
|
<button type="button" class={btn} {disabled} aria-label="Remove {label}" onclick={() => report(controller.removeClipEffect(clip!.id, e.id))}>✕</button>
|
|
119
141
|
</div>
|
|
120
142
|
{#if open === e.id}
|
|
@@ -150,5 +172,14 @@
|
|
|
150
172
|
{#if !clip.effects?.length}<p class="text-[11px] text-muted-foreground">No effects on this clip.</p>{/if}
|
|
151
173
|
</div>
|
|
152
174
|
{/if}
|
|
175
|
+
{#if authoring}
|
|
176
|
+
<div class="space-y-1 rounded border border-border p-1" data-effect-authoring={authoring.instance}>
|
|
177
|
+
<EffectAuthoringEditor session={authoring.session} showSource={false} previewSize={{ width: 240, height: 135 }} />
|
|
178
|
+
<div class="flex gap-1">
|
|
179
|
+
<button type="button" class={btn} {disabled} onclick={commitAuthoring} data-apply-authoring="">Apply to clip</button>
|
|
180
|
+
<button type="button" class={btn} onclick={closeAuthoring}>Close</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
{/if}
|
|
153
184
|
{#if message}<p role="alert" class="text-[11px] text-destructive">{message}</p>{/if}
|
|
154
185
|
</section>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { formatVideoTimecode, parseVideoPreviewScale, VIDEO_PREVIEW_SCALE_OPTIONS, videoProjectHasProxies, type VideoEditorController } from '@pixerate/editor/video-editor';
|
|
2
|
+
import { formatVideoTimecode, parseVideoPreviewScale, VIDEO_PREVIEW_SCALE_OPTIONS, videoAddAdjustmentEdits, videoProjectHasProxies, type VideoEditorController } from '@pixerate/editor/video-editor';
|
|
3
3
|
import { videoEditorStateOf } from './createVideoEditor.svelte.js';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
@@ -15,9 +15,16 @@
|
|
|
15
15
|
const fps = $derived(editor.project.fps);
|
|
16
16
|
const btn = 'rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40';
|
|
17
17
|
const locked = $derived(editor.readOnly);
|
|
18
|
+
function addAdjustment() {
|
|
19
|
+
const r = controller.apply(videoAddAdjustmentEdits(editor.project, editor.time), 'Add adjustment clip');
|
|
20
|
+
if (r.ok) {
|
|
21
|
+
const added = r.outcomes[r.outcomes.length - 1].clip;
|
|
22
|
+
if (added) controller.select([added]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
18
25
|
</script>
|
|
19
26
|
|
|
20
|
-
<!-- Play / pause, frame steps, the playhead's timecode, a preview size when there are proxies, and split, delete, undo and redo. -->
|
|
27
|
+
<!-- Play / pause, frame steps, the playhead's timecode, a preview size when there are proxies, and split, delete, add an adjustment clip, undo and redo. -->
|
|
21
28
|
<div class="flex flex-wrap items-center gap-1.5 text-xs {className}" data-video-transport="">
|
|
22
29
|
<button type="button" class={btn} aria-label="Step back one frame" onclick={() => controller.step(-1)} data-step-back="">◀︎|</button>
|
|
23
30
|
<button type="button" class={btn} aria-label={editor.playing ? 'Pause' : 'Play'} onclick={() => controller.toggle()} disabled={editor.duration <= 0} data-play="">
|
|
@@ -37,6 +44,7 @@
|
|
|
37
44
|
<button type="button" class={btn} disabled={locked} onclick={() => controller.splitAtPlayhead()} title="Split at the playhead (S)" data-split="">Split</button>
|
|
38
45
|
<button type="button" class={btn} disabled={locked || !editor.selection.length} onclick={() => controller.deleteSelection()} title="Delete the selected clips (Delete)" data-delete="">Delete</button>
|
|
39
46
|
<button type="button" class={btn} disabled={locked || !editor.selection.length} onclick={() => controller.deleteSelection({ ripple: true })} title="Delete and close the gaps (Shift+Delete)" data-ripple-delete="">Ripple delete</button>
|
|
47
|
+
<button type="button" class={btn} disabled={locked} onclick={addAdjustment} title="Add an adjustment clip at the playhead over every track: its grade, effects and mask change the pictures underneath" data-add-adjustment="">Adjustment</button>
|
|
40
48
|
<button type="button" class={btn} disabled={locked || !editor.canUndo} onclick={() => controller.undo()} title={editor.canUndo ? `Undo ${editor.undoLabel}` : 'Nothing to undo'} data-undo="">Undo</button>
|
|
41
49
|
<button type="button" class={btn} disabled={locked || !editor.canRedo} onclick={() => controller.redo()} title={editor.canRedo ? `Redo ${editor.redoLabel}` : 'Nothing to redo'} data-redo="">Redo</button>
|
|
42
50
|
</span>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VideoEditorController, type VideoEffectsToolProviderOptions, type VideoEditorOptions, type VideoEditorState, type VideoEditorToolProviderOptions, type VideoEditResult, type VideoProject } from "@pixerate/editor/video-editor";
|
|
1
|
+
import { VideoEditorController, TranscriptVideoController, type TranscriptVideoOptions, type TranscriptVideoState, type VideoDialogueToolProviderOptions, type VideoMusicToolProviderOptions, type VideoEffectsToolProviderOptions, type VideoEditorOptions, type VideoEditorState, type VideoEditorToolProviderOptions, type VideoEditResult, type VideoProject } from "@pixerate/editor/video-editor";
|
|
2
2
|
import type { EditorDocument } from "@pixerate/editor/persistence";
|
|
3
3
|
import type { ToolRegistry } from "@pixerate/editor/tools";
|
|
4
4
|
export interface SvelteVideoEditor {
|
|
@@ -47,3 +47,22 @@ export declare function useVideoEditorTools(registry: ToolRegistry | null | unde
|
|
|
47
47
|
* read tools say so and the write tools refuse.
|
|
48
48
|
*/
|
|
49
49
|
export declare function useVideoEffectsTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoEffectsToolProviderOptions): void;
|
|
50
|
+
/**
|
|
51
|
+
* Registers the talking-head cleanup tools (`find_video_disfluencies`, `plan_video_dialogue_cleanup`)
|
|
52
|
+
* for the lifetime of the calling component (skill `pixerate-talking-head-edit`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function useVideoDialogueTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoDialogueToolProviderOptions): void;
|
|
55
|
+
/**
|
|
56
|
+
* Registers the music and mix tools (`find_video_beats`, `plan_video_beat_snap`, `duck_video_music`,
|
|
57
|
+
* `measure_video_loudness`, `normalize_video_loudness`) for the lifetime of the calling component
|
|
58
|
+
* (skill `pixerate-bgm-alignment`).
|
|
59
|
+
*/
|
|
60
|
+
export declare function useVideoMusicTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoMusicToolProviderOptions): void;
|
|
61
|
+
/**
|
|
62
|
+
* Svelte 5 rune: a `TranscriptVideoController` over a video editor and its reactive state. It is
|
|
63
|
+
* rebuilt when the editor, track or `planFirst` change, and destroyed with the calling component.
|
|
64
|
+
*/
|
|
65
|
+
export declare function transcriptVideoOf(getController: () => VideoEditorController, getOptions?: () => TranscriptVideoOptions): {
|
|
66
|
+
readonly current: TranscriptVideoState;
|
|
67
|
+
readonly transcript: TranscriptVideoController;
|
|
68
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { untrack } from "svelte";
|
|
2
|
-
import { VideoEditorController, createVideoEditorToolProvider, createVideoEffectsToolProvider, createVideoEditorDocument, } from "@pixerate/editor/video-editor";
|
|
2
|
+
import { VideoEditorController, createVideoEditorToolProvider, createVideoEffectsToolProvider, createVideoDialogueToolProvider, createVideoMusicToolProvider, TranscriptVideoController, createVideoEditorDocument, } from "@pixerate/editor/video-editor";
|
|
3
3
|
/**
|
|
4
4
|
* Svelte 5 rune: subscribes to a `VideoEditorController` (or creates one from options) and
|
|
5
5
|
* exposes its state reactively with bound actions.
|
|
@@ -78,3 +78,62 @@ export function useVideoEffectsTools(registry, controller, options = {}) {
|
|
|
78
78
|
return target.register(createVideoEffectsToolProvider(controller, options));
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Registers the talking-head cleanup tools (`find_video_disfluencies`, `plan_video_dialogue_cleanup`)
|
|
83
|
+
* for the lifetime of the calling component (skill `pixerate-talking-head-edit`).
|
|
84
|
+
*/
|
|
85
|
+
export function useVideoDialogueTools(registry, controller, options = {}) {
|
|
86
|
+
$effect(() => {
|
|
87
|
+
const target = typeof registry === "function" ? registry() : registry;
|
|
88
|
+
if (!target)
|
|
89
|
+
return;
|
|
90
|
+
return target.register(createVideoDialogueToolProvider(controller, options));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Registers the music and mix tools (`find_video_beats`, `plan_video_beat_snap`, `duck_video_music`,
|
|
95
|
+
* `measure_video_loudness`, `normalize_video_loudness`) for the lifetime of the calling component
|
|
96
|
+
* (skill `pixerate-bgm-alignment`).
|
|
97
|
+
*/
|
|
98
|
+
export function useVideoMusicTools(registry, controller, options = {}) {
|
|
99
|
+
$effect(() => {
|
|
100
|
+
const target = typeof registry === "function" ? registry() : registry;
|
|
101
|
+
if (!target)
|
|
102
|
+
return;
|
|
103
|
+
return target.register(createVideoMusicToolProvider(controller, options));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Svelte 5 rune: a `TranscriptVideoController` over a video editor and its reactive state. It is
|
|
108
|
+
* rebuilt when the editor, track or `planFirst` change, and destroyed with the calling component.
|
|
109
|
+
*/
|
|
110
|
+
export function transcriptVideoOf(getController, getOptions = () => ({})) {
|
|
111
|
+
const initial = untrack(() => ({ controller: getController(), ...getOptions() }));
|
|
112
|
+
let built = initial;
|
|
113
|
+
let transcript = $state.raw(new TranscriptVideoController(initial.controller, initial));
|
|
114
|
+
let view = $state.raw(untrack(() => transcript.getState()));
|
|
115
|
+
$effect(() => {
|
|
116
|
+
const next = { controller: getController(), ...getOptions() };
|
|
117
|
+
untrack(() => {
|
|
118
|
+
if (next.controller !== built.controller || next.track !== built.track || !!next.planFirst !== !!built.planFirst) {
|
|
119
|
+
transcript.destroy();
|
|
120
|
+
transcript = new TranscriptVideoController(next.controller, next);
|
|
121
|
+
built = next;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const current = untrack(() => transcript);
|
|
125
|
+
view = current.getState();
|
|
126
|
+
return current.subscribe((state) => {
|
|
127
|
+
view = state;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
$effect(() => () => untrack(() => transcript).destroy());
|
|
131
|
+
return {
|
|
132
|
+
get current() {
|
|
133
|
+
return view;
|
|
134
|
+
},
|
|
135
|
+
get transcript() {
|
|
136
|
+
return transcript;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -7,6 +7,8 @@ import type VideoTimelineComponent from "./VideoTimeline.svelte";
|
|
|
7
7
|
import type ClipInspectorComponent from "./ClipInspector.svelte";
|
|
8
8
|
import type MediaBinComponent from "./MediaBin.svelte";
|
|
9
9
|
import type TranscriptPanelComponent from "./TranscriptPanel.svelte";
|
|
10
|
+
import type TranscriptEditorComponent from "./TranscriptEditor.svelte";
|
|
11
|
+
import type EditPlanPreviewComponent from "./EditPlanPreview.svelte";
|
|
10
12
|
import type VideoEffectsPanelComponent from "./VideoEffectsPanel.svelte";
|
|
11
13
|
export { default as VideoEditor } from "./VideoEditor.svelte";
|
|
12
14
|
export { default as VideoPreview } from "./VideoPreview.svelte";
|
|
@@ -16,6 +18,8 @@ export { default as VideoTimeline } from "./VideoTimeline.svelte";
|
|
|
16
18
|
export { default as ClipInspector } from "./ClipInspector.svelte";
|
|
17
19
|
export { default as MediaBin } from "./MediaBin.svelte";
|
|
18
20
|
export { default as TranscriptPanel } from "./TranscriptPanel.svelte";
|
|
21
|
+
export { default as TranscriptEditor } from "./TranscriptEditor.svelte";
|
|
22
|
+
export { default as EditPlanPreview } from "./EditPlanPreview.svelte";
|
|
19
23
|
export { default as VideoEffectsPanel } from "./VideoEffectsPanel.svelte";
|
|
20
24
|
export * from "./createVideoEditor.svelte.js";
|
|
21
25
|
export type VideoEditorProps = ComponentProps<typeof VideoEditorComponent>;
|
|
@@ -26,4 +30,6 @@ export type VideoTimelineProps = ComponentProps<typeof VideoTimelineComponent>;
|
|
|
26
30
|
export type ClipInspectorProps = ComponentProps<typeof ClipInspectorComponent>;
|
|
27
31
|
export type MediaBinProps = ComponentProps<typeof MediaBinComponent>;
|
|
28
32
|
export type TranscriptPanelProps = ComponentProps<typeof TranscriptPanelComponent>;
|
|
33
|
+
export type TranscriptEditorProps = ComponentProps<typeof TranscriptEditorComponent>;
|
|
34
|
+
export type EditPlanPreviewProps = ComponentProps<typeof EditPlanPreviewComponent>;
|
|
29
35
|
export type VideoEffectsPanelProps = ComponentProps<typeof VideoEffectsPanelComponent>;
|
|
@@ -6,5 +6,7 @@ export { default as VideoTimeline } from "./VideoTimeline.svelte";
|
|
|
6
6
|
export { default as ClipInspector } from "./ClipInspector.svelte";
|
|
7
7
|
export { default as MediaBin } from "./MediaBin.svelte";
|
|
8
8
|
export { default as TranscriptPanel } from "./TranscriptPanel.svelte";
|
|
9
|
+
export { default as TranscriptEditor } from "./TranscriptEditor.svelte";
|
|
10
|
+
export { default as EditPlanPreview } from "./EditPlanPreview.svelte";
|
|
9
11
|
export { default as VideoEffectsPanel } from "./VideoEffectsPanel.svelte";
|
|
10
12
|
export * from "./createVideoEditor.svelte.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixerate/editor-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.119.0",
|
|
4
4
|
"description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
|
|
5
5
|
"author": "Pixerate",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,6 +93,11 @@
|
|
|
93
93
|
"svelte": "./dist/color/index.js",
|
|
94
94
|
"default": "./dist/color/index.js"
|
|
95
95
|
},
|
|
96
|
+
"./typography": {
|
|
97
|
+
"types": "./dist/typography/index.d.ts",
|
|
98
|
+
"svelte": "./dist/typography/index.js",
|
|
99
|
+
"default": "./dist/typography/index.js"
|
|
100
|
+
},
|
|
96
101
|
"./gradient": {
|
|
97
102
|
"types": "./dist/gradient/index.d.ts",
|
|
98
103
|
"svelte": "./dist/gradient/index.js",
|
|
@@ -144,6 +149,9 @@
|
|
|
144
149
|
"color": [
|
|
145
150
|
"./dist/color/index.d.ts"
|
|
146
151
|
],
|
|
152
|
+
"typography": [
|
|
153
|
+
"./dist/typography/index.d.ts"
|
|
154
|
+
],
|
|
147
155
|
"gradient": [
|
|
148
156
|
"./dist/gradient/index.d.ts"
|
|
149
157
|
]
|
|
@@ -160,7 +168,7 @@
|
|
|
160
168
|
"@tiptap/extension-bubble-menu": "^2.27.3",
|
|
161
169
|
"@tiptap/extension-placeholder": "^2.27.3",
|
|
162
170
|
"@tiptap/starter-kit": "^2.27.3",
|
|
163
|
-
"@pixerate/editor": "0.
|
|
171
|
+
"@pixerate/editor": "0.162.0"
|
|
164
172
|
},
|
|
165
173
|
"peerDependencies": {
|
|
166
174
|
"@dagrejs/dagre": "^1.1.4",
|