reelkit-cli 0.10.0 → 0.10.3
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/README.md +10 -0
- package/package.json +1 -1
- package/skill/.claude-plugin/plugin.json +7 -0
- package/skill/SKILL.md +16 -1
- package/skill/THIRD_PARTY.md +34 -2
- package/skill/hooks/hooks.json +1 -0
- package/skill/hooks/player.ts +278 -0
- package/skill/hooks/project.ts +227 -0
- package/skill/hooks/register.tsx +533 -0
- package/skill/hooks/timeline.ts +218 -0
- package/skill/reference/art-styles.md +6 -6
- package/skill/reference/component-authoring.md +2 -0
- package/skill/reference/higgsfield-production.md +70 -0
- package/skill/reference/motion-design.md +8 -6
- package/skill/reference/studio-pane.md +31 -0
- package/skill/reference/style-components.md +40 -0
- package/skill/reference/style-recipes.md +86 -0
- package/skill/reference/styles.md +8 -4
- package/skill/tests/studio.test.ts +111 -0
- package/skill/types/index.d.ts +65 -0
- package/src/cli.ts +1 -1
- package/src/commands/assets.ts +9 -0
- package/src/contract/index.ts +8 -3
- package/src/testing/fake-api.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: styles
|
|
3
|
-
description:
|
|
3
|
+
description: Choose a visual language from the material or the user's direction, with restrained and showreel approaches and a catalog of look-specific techniques.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Choosing the look
|
|
@@ -9,8 +9,10 @@ Choose a visual language that fits the material and record it in the first scene
|
|
|
9
9
|
|
|
10
10
|
Use the user's chosen direction. Otherwise recommend a fitting look with a short reason; use the catalogue as technique references, not a mandatory menu. Offer alternatives when requested. Ask only about consequential missing information.
|
|
11
11
|
|
|
12
|
+
After choosing the look, use `reference/style-recipes.md` to connect its visible action, implementation, sound and review evidence. `reference/style-components.md` maps all reviewed shared components to their roles. The catalog's timing, palette and exclusions are defaults for the described treatment; real speech timing, supplied brand identity, the user's requested variation and the installed runtime take precedence.
|
|
13
|
+
|
|
12
14
|
## Two modes
|
|
13
|
-
|
|
15
|
+
Restrained and showreel describe motion energy, not separate engines. Choose the energy by the shot's purpose; a mixed-media film can combine them while preserving its visual thread. The chosen treatment explains which craft defaults in `reference/motion-design.md` change.
|
|
14
16
|
|
|
15
17
|
**Restrained** (product demos, explainers, anything that must feel expensive and calm)
|
|
16
18
|
- One background, one hero colour or black and white, one face.
|
|
@@ -33,7 +35,7 @@ Each look is built from what the kit and the shared library already hold, so mos
|
|
|
33
35
|
**When:** results, growth, a comparison, anything with figures from the narration. **How:** one figure or one chart per scene (search "statistic counter", "line chart", "bar chart", "pie chart", "radial gauge"); the number counts up and finishes before the scene's midpoint; a before and after sits side by side (search "comparison"). The payoff number gets the biggest move of the video. **Forbidden:** any figure that is not in the plan, two charts in one frame, decorative data.
|
|
34
36
|
|
|
35
37
|
### 3. Headline hits (showreel)
|
|
36
|
-
**When:** an opener, a launch, a message of a few words that must stop the scroll. **How:** one or two words fill the frame (search "kinetic title"), hard cuts to a
|
|
38
|
+
**When:** an opener, a launch, a message of a few words that must stop the scroll. **How:** one or two words fill the frame (search "kinetic title"), selected hard cuts to a full colour on a musical or spoken event, and the whole message together at the end, held. Vary the entrance when it helps hierarchy; keep one motion language. A camera push is optional. **Avoid for this treatment:** gradients or outlines that soften the intended heavy graphic read, and motion that prevents reading the landing.
|
|
37
39
|
|
|
38
40
|
### 4. Cinematic story (restrained)
|
|
39
41
|
**When:** a mood, a place, a story told by a voice. **How:** generated or reused pictures with a slow move across them, a film grade and grain over everything, a poster-style title to open (search "cinematic title"), a film overlay if it suits (`--kind overlay`, search "film"). Few words on screen; the captions carry the text. **Forbidden:** interface parts, bright accent colours, fast cuts.
|
|
@@ -51,7 +53,7 @@ Each look is built from what the kit and the shared library already hold, so mos
|
|
|
51
53
|
**When:** a testimonial, an introduction, a creator talking, social proof. **How:** the person's name and role in a lower third (search "name lower third"), a quote card with its stars (search "quote testimonial"), a follow line to close (search "social handle", "cta button"). Quotes and names come from the user, word for word. **Forbidden:** an invented quote, rating or follower count.
|
|
52
54
|
|
|
53
55
|
### 9. Over footage (either mode)
|
|
54
|
-
**When:** the user supplied their own video. **How:** the footage is the picture; graphics
|
|
56
|
+
**When:** the user supplied their own video. **How:** the footage is the picture; graphics support it. Keep the active graphic clear of the speaker's face; use a punch-in only for needed emphasis or framing, with lower thirds and callouts from the library. Honor the user's caption choice and use actual source-speech timings (`reference/studio-editing.md`). **Avoid:** covering the face, a graphic left after its purpose ends, zooms stacked on footage that already has its own.
|
|
55
57
|
|
|
56
58
|
### 10. Logo sting (either mode)
|
|
57
59
|
**When:** an opener or end card, from the user's own logo file. **How:** build toward the logo from its own shapes and colours and land on it exactly as supplied, held at least 1.5 seconds. **Forbidden:** redrawing, recolouring or stretching the logo; anyone else's logo or trademark.
|
|
@@ -71,6 +73,8 @@ Each look is built from what the kit and the shared library already hold, so mos
|
|
|
71
73
|
## Your own
|
|
72
74
|
Use the supplied message, actions, colours, duration and format. Ask only for consequential missing details or a reference that the choice depends on. Choose the nearest look above as the technical base and write a four-line brief in the same shape (when, how, forbidden, structure). Show the brief with the plan at the checkpoint.
|
|
73
75
|
|
|
76
|
+
For morphing UI, particle words, cube worlds, text rings, children's-book cutaways, blueprint-to-product shots, exploded assembly, toon materials, layered posters, mixed-media narration, cutout depth and composited VFX, use the advanced rows 29 to 40 in `reference/style-recipes.md`. Select a local kit or external production route that can actually deliver the shot.
|
|
77
|
+
|
|
74
78
|
## What every look shares
|
|
75
79
|
- The plan checkpoint lists the exact words that will appear on screen and when. Fixing a list takes a minute; fixing a finished animation takes an hour.
|
|
76
80
|
- Describe the look to the user in plain words about what they will see ("the title sits on a soft dark patch"), not in studio terms.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
const DIR = '/work/demo'
|
|
4
|
+
const PANE = { title: 'Reelkit', isFocused: false, bodyColumns: 100, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} } as const
|
|
5
|
+
|
|
6
|
+
const FILES: Record<string, string> = {
|
|
7
|
+
[`${DIR}/reelkit.json`]: JSON.stringify({ name: 'demo', aspect: '9:16' }),
|
|
8
|
+
[`${DIR}/plan.json`]: JSON.stringify({
|
|
9
|
+
title: 'Demo film',
|
|
10
|
+
aspect: '9:16',
|
|
11
|
+
voiceId: 'v-1',
|
|
12
|
+
scenes: [
|
|
13
|
+
{ id: 'hook', narration: 'First the hook lands.', treatment: 'motion-graphic', onScreenText: ['HOOK'] },
|
|
14
|
+
{ id: 'proof', narration: 'Then the proof follows.', treatment: 'illustration', onScreenText: ['PROOF'] },
|
|
15
|
+
],
|
|
16
|
+
}),
|
|
17
|
+
[`${DIR}/manifest.json`]: JSON.stringify({
|
|
18
|
+
fps: 30,
|
|
19
|
+
totalFrames: 240,
|
|
20
|
+
music: { key: 'assets/lib/music-a/track.mp3', bpm: 120, beatFrames: [0, 15, 30, 45] },
|
|
21
|
+
scenes: [
|
|
22
|
+
{ id: 'hook', startFrame: 0, durationFrames: 90, words: [{ word: 'First', startSec: 0.1, endSec: 0.4 }] },
|
|
23
|
+
{ id: 'proof', startFrame: 90, durationFrames: 150, words: [{ word: 'Then', startSec: 0.2, endSec: 0.5 }] },
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
[`${DIR}/assets/music.json`]: JSON.stringify({ key: 'assets/lib/music-a/track.mp3', title: 'Track A', durationSec: 20 }),
|
|
27
|
+
[`${DIR}/src/Badge.tsx`]: '// A round badge with one word in it. Use it to mark a claim.\nexport const Badge = () => null\n',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const entry = (name: string) => ({ name, kind: 'file' as const, size: 10, mtimeMs: 1000, isLink: false })
|
|
31
|
+
|
|
32
|
+
// The engine beneath the mod: one project on disk, a render, and commands that answer from memory.
|
|
33
|
+
type TestOn = Parameters<Extract<Parameters<typeof test>[1], (...args: never[]) => unknown>>[1]
|
|
34
|
+
|
|
35
|
+
const project = (on: TestOn, written: Record<string, string>, ran: string[][]) => {
|
|
36
|
+
on('session.cwd', () => ({ value: DIR }))
|
|
37
|
+
on('clock.now', () => ({ value: 3600000 }))
|
|
38
|
+
on('ui.open', () => ({ value: { isPlaced: true as const } }))
|
|
39
|
+
on('fs.exists', (_$, e) => ({ value: e.path in FILES }))
|
|
40
|
+
on('fs.read', (_$, e) => {
|
|
41
|
+
const text = FILES[e.path]
|
|
42
|
+
if (text === undefined) throw new Error(`ENOENT ${e.path}`)
|
|
43
|
+
return { value: text }
|
|
44
|
+
})
|
|
45
|
+
on('fs.list', (_$, e) => ({ value: e.path === `${DIR}/src` ? [entry('Badge.tsx'), entry('Video.tsx')] : [] }))
|
|
46
|
+
on('fs.stat', (_$, e) => {
|
|
47
|
+
if (e.path !== `${DIR}/out/video.mp4`) throw new Error(`ENOENT ${e.path}`)
|
|
48
|
+
return { value: { kind: 'file' as const, size: 2097152, mtimeMs: 5000, isLink: false } }
|
|
49
|
+
})
|
|
50
|
+
on('fs.write', (_$, e) => {
|
|
51
|
+
written[e.path] = e.text
|
|
52
|
+
return { value: undefined }
|
|
53
|
+
})
|
|
54
|
+
on('process.run', (_$, e) => {
|
|
55
|
+
ran.push([...e.argv])
|
|
56
|
+
const stdout =
|
|
57
|
+
e.argv[0] === 'reelkit'
|
|
58
|
+
? JSON.stringify({ ok: true, data: { items: [{ id: 'music-b', kind: 'music', title: 'Track B', description: 'Bright.', match: 0.8, meta: { bpm: 100 } }] } })
|
|
59
|
+
: ''
|
|
60
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('the pane shows the film as a timeline on every surface', async ($, on) => {
|
|
65
|
+
project(on, {}, [])
|
|
66
|
+
for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) {
|
|
67
|
+
const ui = await $.ui.mount({ plugin: 'reelkit', surface, component: 'Pane', requestId: 'reelkit-studio', props: PANE })
|
|
68
|
+
await ui.press({ key: 'refresh' })
|
|
69
|
+
expect(await ui.find({ type: 'Text', text: /Demo film/ })).toBeDefined()
|
|
70
|
+
expect(await ui.find({ type: 'Text', text: /1\. hook/ })).toBeDefined()
|
|
71
|
+
if (surface === 'terminal') expect(await ui.find({ type: 'Text', text: /1 hook/ })).toBeDefined()
|
|
72
|
+
else expect(await ui.find({ type: 'Svg' })).toBeDefined()
|
|
73
|
+
await ui.press({ key: 'next' })
|
|
74
|
+
expect(await ui.find({ type: 'Text', text: /2\. proof/ })).toBeDefined()
|
|
75
|
+
await ui.press({ key: 'prev' })
|
|
76
|
+
await ui.unmount()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('the tabs show the plan, the music and the components', async ($, on) => {
|
|
81
|
+
project(on, {}, [])
|
|
82
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
83
|
+
const ui = await $.ui.mount({ plugin: 'reelkit', surface, component: 'Pane', requestId: 'reelkit-studio', props: PANE })
|
|
84
|
+
await ui.press({ key: 'refresh' })
|
|
85
|
+
await ui.press({ key: 'tab-plan' })
|
|
86
|
+
expect((await ui.find({ type: 'Markdown' }))?.props.text).toContain('Then the proof follows.')
|
|
87
|
+
await ui.press({ key: 'tab-music' })
|
|
88
|
+
expect(await ui.find({ type: 'Text', text: /Track A/ })).toBeDefined()
|
|
89
|
+
await ui.press({ key: 'tab-components' })
|
|
90
|
+
expect(await ui.find({ type: 'Text', text: /round badge/ })).toBeDefined()
|
|
91
|
+
await ui.press({ key: 'tab-timeline' })
|
|
92
|
+
await ui.unmount()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('the browser tab searches the library and the player page is written beside the render', async ($, on) => {
|
|
97
|
+
const written: Record<string, string> = {}
|
|
98
|
+
const ran: string[][] = []
|
|
99
|
+
project(on, written, ran)
|
|
100
|
+
const ui = await $.ui.mount({ plugin: 'reelkit', surface: 'desktop', component: 'Pane', requestId: 'reelkit-studio', props: PANE })
|
|
101
|
+
await ui.press({ key: 'refresh' })
|
|
102
|
+
await ui.press({ key: 'player' })
|
|
103
|
+
expect(written[`${DIR}/out/studio.html`]).toContain('video.mp4')
|
|
104
|
+
expect(ran.some(argv => argv[0] === 'open' && argv[1] === `${DIR}/out/studio.html`)).toBe(true)
|
|
105
|
+
await ui.press({ key: 'tab-browser' })
|
|
106
|
+
await ui.input({ key: 'query', text: 'bright' })
|
|
107
|
+
expect(ran.some(argv => argv.join(' ') === 'reelkit assets search bright --kind music --json')).toBe(true)
|
|
108
|
+
expect(await ui.find({ type: 'Text', text: /80% Track B/ })).toBeDefined()
|
|
109
|
+
await ui.press({ key: 'tab-timeline' })
|
|
110
|
+
await ui.unmount()
|
|
111
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type StudioTab = 'timeline' | 'plan' | 'music' | 'components' | 'browser'
|
|
2
|
+
|
|
3
|
+
export type FilmScene = {
|
|
4
|
+
id: string
|
|
5
|
+
// Frames from the film's start, and the scene's own length in frames.
|
|
6
|
+
start: number
|
|
7
|
+
frames: number
|
|
8
|
+
narration: string
|
|
9
|
+
text: string[]
|
|
10
|
+
treatment: string
|
|
11
|
+
notes: string
|
|
12
|
+
// Seconds from the scene's start at which each spoken word begins.
|
|
13
|
+
words: number[]
|
|
14
|
+
// A small picture of the scene under out/.studio, when a preview frame exists.
|
|
15
|
+
thumb: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type FilmSound = { id: string; kind: string; title: string; path: string; seconds: number }
|
|
19
|
+
|
|
20
|
+
export type FilmComponent = { name: string; path: string; about: string }
|
|
21
|
+
|
|
22
|
+
export type Film = {
|
|
23
|
+
dir: string
|
|
24
|
+
name: string
|
|
25
|
+
title: string
|
|
26
|
+
aspect: string
|
|
27
|
+
fps: number
|
|
28
|
+
totalFrames: number
|
|
29
|
+
// True once manifest.json gave real timings; false while they are estimated from the plan.
|
|
30
|
+
isTimed: boolean
|
|
31
|
+
voice: string
|
|
32
|
+
pace: string
|
|
33
|
+
captions: string
|
|
34
|
+
scenes: FilmScene[]
|
|
35
|
+
music: { title: string; path: string; bpm: number; seconds: number; beats: number[] } | null
|
|
36
|
+
sounds: FilmSound[]
|
|
37
|
+
components: FilmComponent[]
|
|
38
|
+
userAssets: { id: string; kind: string; name: string; about: string }[]
|
|
39
|
+
render: { path: string; bytes: number; at: number } | null
|
|
40
|
+
previews: number
|
|
41
|
+
loadedAt: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LibraryItem = { id: string; kind: string; title: string; about: string; match: number; detail: string }
|
|
45
|
+
|
|
46
|
+
export type LibrarySearch = {
|
|
47
|
+
kind: string
|
|
48
|
+
query: string
|
|
49
|
+
status: 'idle' | 'busy' | 'done' | 'failed'
|
|
50
|
+
note: string
|
|
51
|
+
items: LibraryItem[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
declare module 'claude-code' {
|
|
55
|
+
interface PluginState {
|
|
56
|
+
reelkit: {
|
|
57
|
+
tab: StudioTab
|
|
58
|
+
film: Film | null
|
|
59
|
+
projects: string[]
|
|
60
|
+
scene: number
|
|
61
|
+
component: string
|
|
62
|
+
search: LibrarySearch
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -76,7 +76,7 @@ assets.command("upload <file>").description("Add one of your own files to this p
|
|
|
76
76
|
.option("--background", "use this video or image as the ground behind the whole film (it stays on this machine)").option("--share", "also send it to the shared library").option("--kind <kind>", "library kind when sharing").option("--tags <tags>", "comma-separated tags when sharing")
|
|
77
77
|
// Called with the arguments named: commander adds its own command object last, which must not be taken for the function's test hooks.
|
|
78
78
|
.action(run((ctx, file: string, opts: Parameters<typeof assetsUpload>[2]) => assetsUpload(ctx, file, opts)));
|
|
79
|
-
assets.command("search <query>").description("Search the shared library by meaning; each result shows how well it fits").option("--kind <kind>", "image, overlay, sfx, music, component or
|
|
79
|
+
assets.command("search <query>").description("Search the shared library by meaning; each result shows how well it fits").option("--kind <kind>", "image, overlay, sfx, music, component, clip or prompt (prompts other people used to make motion videos; found only with --kind prompt)").option("--limit <n>", "how many results")
|
|
80
80
|
.action(run(assetsSearch));
|
|
81
81
|
assets.command("pull <id>").description("Download a library item into this project").option("--scene <sceneId>", "use it as this scene's image or clip").option("--force", "replace a component file that already exists").option("--music", "make this music item the video's track (its tempo is measured and scene changes land on its beat)").option("--background", "make this clip, overlay or image the ground behind the film (with --scene: the ground of that scene, an image only)")
|
|
82
82
|
.option("--layers", "with --scene and an image: right after it is pulled, cut the subject out as a layer (about 1 second of the cutout quota; the picture is sent to the Reelkit server)")
|
package/src/commands/assets.ts
CHANGED
|
@@ -292,6 +292,15 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
|
|
|
292
292
|
if (treatment === "clip" && pulled.item.kind !== "clip") return { ok: false, summary: `${id} is ${pulled.item.kind}, not a clip, so it cannot be scene ${opts.scene}'s clip.` };
|
|
293
293
|
if (treatment !== "clip" && pulled.item.kind !== "image") return { ok: false, summary: `${id} is ${pulled.item.kind}, not an image, so it cannot be a scene's image.` };
|
|
294
294
|
}
|
|
295
|
+
// A prompt is something to read, not something the film shows: it goes to prompts/, outside what the composition can reference.
|
|
296
|
+
if (pulled.item.kind === "prompt") {
|
|
297
|
+
if (opts.scene || opts.music || opts.background) return { ok: false, summary: `${id} is a prompt: text to read for ideas, not a picture, clip or track. Pull it with no option.` };
|
|
298
|
+
const path = `prompts/${id}.md`;
|
|
299
|
+
await download(pulled.url, project.path(path));
|
|
300
|
+
const by = typeof pulled.item.meta.handle === "string" ? ` by @${pulled.item.meta.handle}` : "";
|
|
301
|
+
const source = typeof pulled.item.meta.source === "string" ? ` Source: ${pulled.item.meta.source}` : "";
|
|
302
|
+
return { ok: true, data: { path, kind: "prompt" }, summary: `Pulled ${id} to ${path}: "${pulled.item.title}"${by}. Read it for how the video was asked for; it is another person's prompt, so borrow the approach, not the wording.${source}` };
|
|
303
|
+
}
|
|
295
304
|
// A component is code, not media: it goes beside Video.tsx, which may only import files in src/.
|
|
296
305
|
if (pulled.item.kind === "component") {
|
|
297
306
|
const path = `src/${pulled.filename}`;
|
package/src/contract/index.ts
CHANGED
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
// that the item is good enough to reuse for the query. A server finds candidates by meaning and may judge them with a model, so `match` is not
|
|
39
39
|
// a share of words; the order of results with the same `match` is not specified. The public listing is newest first; with a query it is best
|
|
40
40
|
// match first, equal matches newest first. Paging continues the same order, with no item twice. `page` is from 1 to 10000.
|
|
41
|
+
// - A `prompt` is a text item: a prompt someone wrote to make a motion video, kept as a Markdown file with its author and source in `meta`
|
|
42
|
+
// (`author`, `handle`, `source`, and for a skill `install` and `repo`). It is reference material, not an asset, so search returns prompts
|
|
43
|
+
// only when asked with `kind: "prompt"`; a search with no kind never contains one (a client older than the kind cannot read it).
|
|
44
|
+
// A prompt cannot be uploaded: `libraryUpload` with `kind: "prompt"` is 400 `invalid_request`.
|
|
41
45
|
// - A server may add tags (and a description when the uploader gave none) to an item that is shared, after upload or generation; the tags the
|
|
42
46
|
// uploader gave stay in it, first.
|
|
43
47
|
// - Upload URLs and file URLs must be unguessable (random or signed; never derived from an item id or a counter) and scoped to one object:
|
|
@@ -132,10 +136,11 @@ export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
|
|
|
132
136
|
// The code is any string here: a code this version does not know still carries a message worth showing (the client maps it to server_error).
|
|
133
137
|
export const ApiErrorSchema = z.object({ error: z.object({ code: z.string(), message: z.string() }) });
|
|
134
138
|
|
|
135
|
-
export const LibraryKindSchema = z.enum(["image", "overlay", "sfx", "music", "component", "clip"]);
|
|
139
|
+
export const LibraryKindSchema = z.enum(["image", "overlay", "sfx", "music", "component", "clip", "prompt"]);
|
|
136
140
|
export type LibraryKind = z.infer<typeof LibraryKindSchema>;
|
|
137
|
-
// What a user may upload: every kind. A component is code and is held for
|
|
138
|
-
|
|
141
|
+
// What a user may upload: every kind but a prompt (those are published by the library's own import). A component is code and is held for
|
|
142
|
+
// review (see the rules at the top).
|
|
143
|
+
export const UploadKindSchema = LibraryKindSchema.exclude(["prompt"]);
|
|
139
144
|
export type UploadKind = z.infer<typeof UploadKindSchema>;
|
|
140
145
|
|
|
141
146
|
export const LibraryItemSchema = z.object({
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -158,7 +158,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
158
158
|
// Best match first; items that match equally well come newest first (the one added last).
|
|
159
159
|
// match is the share of the query's words the item has, to two decimals.
|
|
160
160
|
const items = [...store.values()]
|
|
161
|
-
.filter((s) => s.item.visibility === "published" && (
|
|
161
|
+
.filter((s) => s.item.visibility === "published" && (kind ? s.item.kind === kind : s.item.kind !== "prompt"))
|
|
162
162
|
.map((s, order) => ({ s, order, score: [...want].filter((w) => words(`${s.item.title} ${s.item.description} ${s.item.tags.join(" ")}`).has(w)).length }))
|
|
163
163
|
.filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.order - a.order).slice(0, limit ?? 8)
|
|
164
164
|
.map((x) => ({ ...x.s.item, match: Math.round((x.score / want.size) * 100) / 100 }));
|