jcheng5 commited on
Commit
21e6506
·
1 Parent(s): d8b5604

Initial checkin

Browse files
Dockerfile CHANGED
@@ -1,6 +1,7 @@
1
- FROM python:3.9
2
 
3
  WORKDIR /code
 
4
 
5
  COPY ./requirements.txt /code/requirements.txt
6
 
@@ -8,6 +9,8 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
8
 
9
  COPY . .
10
 
 
 
11
  EXPOSE 7860
12
 
13
- CMD ["shiny", "run", "app.py", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.10
2
 
3
  WORKDIR /code
4
+ RUN apt install -y ffmpeg
5
 
6
  COPY ./requirements.txt /code/requirements.txt
7
 
 
9
 
10
  COPY . .
11
 
12
+ RUN python preload.py
13
+
14
  EXPOSE 7860
15
 
16
+ CMD ["shiny", "run", "app.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,20 +1,34 @@
1
- ---
2
- title: Multimodal
3
- emoji: 🌍
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
 
11
- This is a templated Space for [Shiny for Python](https://shiny.rstudio.com/py/).
12
 
 
13
 
14
- To get started with a new app do the following:
15
 
16
- 1) Install Shiny with `pip install shiny`
17
- 2) Create a new app with `shiny create .`
18
- 3) Then run the app with `shiny run --reload`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
- To learn more about this framework please see the [Documentation](https://shiny.rstudio.com/py/docs/overview.html).
 
1
+ # Video in, audio out
 
 
 
 
 
 
 
 
2
 
3
+ This is a [Shiny for Python](https://shiny.posit.co/py/) app for easily interacting with GPT-4o via short webcam recordings.
4
 
5
+ ## Installation
6
 
7
+ ### ffmpeg
8
 
9
+ You will need the `ffmpeg` utility installed. Either use the [official installers](https://ffmpeg.org/download.html), or `brew install ffmpeg` (for macOS brew users) or `choco install ffmpeg` (for Windows chocolatey users).
10
+
11
+ ### OpenAI API key
12
+
13
+ Create a file called `.env` in the root of the project and add the following line:
14
+
15
+ ```
16
+ OPENAI_API_KEY=<your-api-key>
17
+ ```
18
+
19
+ If you have an OpenAI account, you can generate an API key from [this page](https://platform.openai.com/api-keys).
20
+
21
+ ### Python dependencies
22
+
23
+ ```
24
+ pip install -r requirements.txt
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ shiny run app.py --port 0 --launch-browser
31
+ ```
32
+
33
+ This will launch a browser window with a video preview. Press Record, speak your prompt, and press Stop. The video will be processed and the response will be read aloud.
34
 
 
app.py CHANGED
@@ -1,151 +1,53 @@
1
- from pathlib import Path
2
- from typing import List, Dict, Tuple
3
- import matplotlib.colors as mpl_colors
4
 
5
- import pandas as pd
6
- import seaborn as sns
7
- import shinyswatch
8
 
9
- from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
10
 
11
- sns.set_theme()
12
 
13
- www_dir = Path(__file__).parent.resolve() / "www"
14
 
15
- df = pd.read_csv(Path(__file__).parent / "penguins.csv", na_values="NA")
16
- numeric_cols: List[str] = df.select_dtypes(include=["float64"]).columns.tolist()
17
- species: List[str] = df["Species"].unique().tolist()
18
- species.sort()
19
 
20
- app_ui = ui.page_fillable(
21
- shinyswatch.theme.minty(),
22
- ui.layout_sidebar(
23
- ui.sidebar(
24
- # Artwork by @allison_horst
25
- ui.input_selectize(
26
- "xvar",
27
- "X variable",
28
- numeric_cols,
29
- selected="Bill Length (mm)",
30
- ),
31
- ui.input_selectize(
32
- "yvar",
33
- "Y variable",
34
- numeric_cols,
35
- selected="Bill Depth (mm)",
36
- ),
37
- ui.input_checkbox_group(
38
- "species", "Filter by species", species, selected=species
39
- ),
40
- ui.hr(),
41
- ui.input_switch("by_species", "Show species", value=True),
42
- ui.input_switch("show_margins", "Show marginal plots", value=True),
43
- ),
44
- ui.output_ui("value_boxes"),
45
- ui.output_plot("scatter", fill=True),
46
- ui.help_text(
47
- "Artwork by ",
48
- ui.a("@allison_horst", href="https://twitter.com/allison_horst"),
49
- class_="text-end",
50
- ),
51
- ),
52
- )
53
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- def server(input: Inputs, output: Outputs, session: Session):
56
- @reactive.Calc
57
- def filtered_df() -> pd.DataFrame:
58
- """Returns a Pandas data frame that includes only the desired rows"""
59
 
60
- # This calculation "req"uires that at least one species is selected
61
- req(len(input.species()) > 0)
62
-
63
- # Filter the rows so we only include the desired species
64
- return df[df["Species"].isin(input.species())]
65
-
66
- @output
67
- @render.plot
68
- def scatter():
69
- """Generates a plot for Shiny to display to the user"""
70
-
71
- # The plotting function to use depends on whether margins are desired
72
- plotfunc = sns.jointplot if input.show_margins() else sns.scatterplot
73
-
74
- plotfunc(
75
- data=filtered_df(),
76
- x=input.xvar(),
77
- y=input.yvar(),
78
- palette=palette,
79
- hue="Species" if input.by_species() else None,
80
- hue_order=species,
81
- legend=False,
82
- )
83
-
84
- @output
85
- @render.ui
86
- def value_boxes():
87
- df = filtered_df()
88
-
89
- def penguin_value_box(title: str, count: int, bgcol: str, showcase_img: str):
90
- return ui.value_box(
91
- title,
92
- count,
93
- {"class_": "pt-1 pb-0"},
94
- showcase=ui.fill.as_fill_item(
95
- ui.tags.img(
96
- {"style": "object-fit:contain;"},
97
- src=showcase_img,
98
- )
99
- ),
100
- theme_color=None,
101
- style=f"background-color: {bgcol};",
102
  )
 
103
 
104
- if not input.by_species():
105
- return penguin_value_box(
106
- "Penguins",
107
- len(df.index),
108
- bg_palette["default"],
109
- # Artwork by @allison_horst
110
- showcase_img="penguins.png",
111
- )
112
-
113
- value_boxes = [
114
- penguin_value_box(
115
- name,
116
- len(df[df["Species"] == name]),
117
- bg_palette[name],
118
- # Artwork by @allison_horst
119
- showcase_img=f"{name}.png",
120
- )
121
- for name in species
122
- # Only include boxes for _selected_ species
123
- if name in input.species()
124
- ]
125
-
126
- return ui.layout_column_wrap(*value_boxes, width = 1 / len(value_boxes))
127
-
128
-
129
- # "darkorange", "purple", "cyan4"
130
- colors = [[255, 140, 0], [160, 32, 240], [0, 139, 139]]
131
- colors = [(r / 255.0, g / 255.0, b / 255.0) for r, g, b in colors]
132
-
133
- palette: Dict[str, Tuple[float, float, float]] = {
134
- "Adelie": colors[0],
135
- "Chinstrap": colors[1],
136
- "Gentoo": colors[2],
137
- "default": sns.color_palette()[0], # type: ignore
138
- }
139
 
140
- bg_palette = {}
141
- # Use `sns.set_style("whitegrid")` to help find approx alpha value
142
- for name, col in palette.items():
143
- # Adjusted n_colors until `axe` accessibility did not complain about color contrast
144
- bg_palette[name] = mpl_colors.to_hex(sns.light_palette(col, n_colors=7)[1]) # type: ignore
145
 
 
 
 
 
 
 
 
 
 
 
146
 
147
- app = App(
148
- app_ui,
149
- server,
150
- static_assets=str(www_dir),
151
- )
 
1
+ import base64
2
+ import tempfile
 
3
 
4
+ from openai import AsyncOpenAI
5
+ from shiny.express import input, render, ui
 
6
 
7
+ from videoinput import audio_spinner, input_video_clip, process_video
8
 
9
+ client = AsyncOpenAI()
10
 
11
+ ui.page_opts(class_="py-5")
12
 
13
+ input_video_clip("clip")
 
 
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ @render.ui
17
+ async def show_clip():
18
+ clip = input.clip()
19
+ mime_type = clip["type"]
20
+ bytes = base64.b64decode(clip["bytes"])
21
+ # TODO: Use correct file extension based on mime type
22
+ with tempfile.TemporaryDirectory() as tempdir:
23
+ filename = tempfile.mktemp(dir=tempdir, suffix=get_video_extension(mime_type))
24
+ with open(filename, "wb") as file:
25
+ file.write(bytes)
26
+ file.close()
27
 
28
+ with ui.Progress() as p:
 
 
 
29
 
30
+ mp3_data_uri = await process_video(
31
+ client,
32
+ filename,
33
+ callback=lambda status: p.set(message=status),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  )
35
+ return audio_spinner(src=mp3_data_uri)
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
+ def get_video_extension(mime_type: str) -> str:
39
+ mime_type = mime_type.split(";")[0].strip()
 
 
 
40
 
41
+ # Dictionary to map MIME types to file extensions
42
+ mime_to_extension = {
43
+ "video/webm": ".webm",
44
+ "video/mp4": ".mp4",
45
+ "video/ogg": ".ogv",
46
+ "video/x-matroska": ".mkv",
47
+ "video/avi": ".avi",
48
+ "video/mpeg": ".mpeg",
49
+ "video/quicktime": ".mov",
50
+ }
51
 
52
+ # Return the appropriate file extension for the given MIME type
53
+ return mime_to_extension.get(mime_type, "")
 
 
 
penguins.csv DELETED
@@ -1,345 +0,0 @@
1
- Species,Island,Bill Length (mm),Bill Depth (mm),Flipper Length (mm),Body Mass (g),Sex,Year
2
- Adelie,Torgersen,39.1,18.7,181,3750,male,2007
3
- Adelie,Torgersen,39.5,17.4,186,3800,female,2007
4
- Adelie,Torgersen,40.3,18,195,3250,female,2007
5
- Adelie,Torgersen,NA,NA,NA,NA,NA,2007
6
- Adelie,Torgersen,36.7,19.3,193,3450,female,2007
7
- Adelie,Torgersen,39.3,20.6,190,3650,male,2007
8
- Adelie,Torgersen,38.9,17.8,181,3625,female,2007
9
- Adelie,Torgersen,39.2,19.6,195,4675,male,2007
10
- Adelie,Torgersen,34.1,18.1,193,3475,NA,2007
11
- Adelie,Torgersen,42,20.2,190,4250,NA,2007
12
- Adelie,Torgersen,37.8,17.1,186,3300,NA,2007
13
- Adelie,Torgersen,37.8,17.3,180,3700,NA,2007
14
- Adelie,Torgersen,41.1,17.6,182,3200,female,2007
15
- Adelie,Torgersen,38.6,21.2,191,3800,male,2007
16
- Adelie,Torgersen,34.6,21.1,198,4400,male,2007
17
- Adelie,Torgersen,36.6,17.8,185,3700,female,2007
18
- Adelie,Torgersen,38.7,19,195,3450,female,2007
19
- Adelie,Torgersen,42.5,20.7,197,4500,male,2007
20
- Adelie,Torgersen,34.4,18.4,184,3325,female,2007
21
- Adelie,Torgersen,46,21.5,194,4200,male,2007
22
- Adelie,Biscoe,37.8,18.3,174,3400,female,2007
23
- Adelie,Biscoe,37.7,18.7,180,3600,male,2007
24
- Adelie,Biscoe,35.9,19.2,189,3800,female,2007
25
- Adelie,Biscoe,38.2,18.1,185,3950,male,2007
26
- Adelie,Biscoe,38.8,17.2,180,3800,male,2007
27
- Adelie,Biscoe,35.3,18.9,187,3800,female,2007
28
- Adelie,Biscoe,40.6,18.6,183,3550,male,2007
29
- Adelie,Biscoe,40.5,17.9,187,3200,female,2007
30
- Adelie,Biscoe,37.9,18.6,172,3150,female,2007
31
- Adelie,Biscoe,40.5,18.9,180,3950,male,2007
32
- Adelie,Dream,39.5,16.7,178,3250,female,2007
33
- Adelie,Dream,37.2,18.1,178,3900,male,2007
34
- Adelie,Dream,39.5,17.8,188,3300,female,2007
35
- Adelie,Dream,40.9,18.9,184,3900,male,2007
36
- Adelie,Dream,36.4,17,195,3325,female,2007
37
- Adelie,Dream,39.2,21.1,196,4150,male,2007
38
- Adelie,Dream,38.8,20,190,3950,male,2007
39
- Adelie,Dream,42.2,18.5,180,3550,female,2007
40
- Adelie,Dream,37.6,19.3,181,3300,female,2007
41
- Adelie,Dream,39.8,19.1,184,4650,male,2007
42
- Adelie,Dream,36.5,18,182,3150,female,2007
43
- Adelie,Dream,40.8,18.4,195,3900,male,2007
44
- Adelie,Dream,36,18.5,186,3100,female,2007
45
- Adelie,Dream,44.1,19.7,196,4400,male,2007
46
- Adelie,Dream,37,16.9,185,3000,female,2007
47
- Adelie,Dream,39.6,18.8,190,4600,male,2007
48
- Adelie,Dream,41.1,19,182,3425,male,2007
49
- Adelie,Dream,37.5,18.9,179,2975,NA,2007
50
- Adelie,Dream,36,17.9,190,3450,female,2007
51
- Adelie,Dream,42.3,21.2,191,4150,male,2007
52
- Adelie,Biscoe,39.6,17.7,186,3500,female,2008
53
- Adelie,Biscoe,40.1,18.9,188,4300,male,2008
54
- Adelie,Biscoe,35,17.9,190,3450,female,2008
55
- Adelie,Biscoe,42,19.5,200,4050,male,2008
56
- Adelie,Biscoe,34.5,18.1,187,2900,female,2008
57
- Adelie,Biscoe,41.4,18.6,191,3700,male,2008
58
- Adelie,Biscoe,39,17.5,186,3550,female,2008
59
- Adelie,Biscoe,40.6,18.8,193,3800,male,2008
60
- Adelie,Biscoe,36.5,16.6,181,2850,female,2008
61
- Adelie,Biscoe,37.6,19.1,194,3750,male,2008
62
- Adelie,Biscoe,35.7,16.9,185,3150,female,2008
63
- Adelie,Biscoe,41.3,21.1,195,4400,male,2008
64
- Adelie,Biscoe,37.6,17,185,3600,female,2008
65
- Adelie,Biscoe,41.1,18.2,192,4050,male,2008
66
- Adelie,Biscoe,36.4,17.1,184,2850,female,2008
67
- Adelie,Biscoe,41.6,18,192,3950,male,2008
68
- Adelie,Biscoe,35.5,16.2,195,3350,female,2008
69
- Adelie,Biscoe,41.1,19.1,188,4100,male,2008
70
- Adelie,Torgersen,35.9,16.6,190,3050,female,2008
71
- Adelie,Torgersen,41.8,19.4,198,4450,male,2008
72
- Adelie,Torgersen,33.5,19,190,3600,female,2008
73
- Adelie,Torgersen,39.7,18.4,190,3900,male,2008
74
- Adelie,Torgersen,39.6,17.2,196,3550,female,2008
75
- Adelie,Torgersen,45.8,18.9,197,4150,male,2008
76
- Adelie,Torgersen,35.5,17.5,190,3700,female,2008
77
- Adelie,Torgersen,42.8,18.5,195,4250,male,2008
78
- Adelie,Torgersen,40.9,16.8,191,3700,female,2008
79
- Adelie,Torgersen,37.2,19.4,184,3900,male,2008
80
- Adelie,Torgersen,36.2,16.1,187,3550,female,2008
81
- Adelie,Torgersen,42.1,19.1,195,4000,male,2008
82
- Adelie,Torgersen,34.6,17.2,189,3200,female,2008
83
- Adelie,Torgersen,42.9,17.6,196,4700,male,2008
84
- Adelie,Torgersen,36.7,18.8,187,3800,female,2008
85
- Adelie,Torgersen,35.1,19.4,193,4200,male,2008
86
- Adelie,Dream,37.3,17.8,191,3350,female,2008
87
- Adelie,Dream,41.3,20.3,194,3550,male,2008
88
- Adelie,Dream,36.3,19.5,190,3800,male,2008
89
- Adelie,Dream,36.9,18.6,189,3500,female,2008
90
- Adelie,Dream,38.3,19.2,189,3950,male,2008
91
- Adelie,Dream,38.9,18.8,190,3600,female,2008
92
- Adelie,Dream,35.7,18,202,3550,female,2008
93
- Adelie,Dream,41.1,18.1,205,4300,male,2008
94
- Adelie,Dream,34,17.1,185,3400,female,2008
95
- Adelie,Dream,39.6,18.1,186,4450,male,2008
96
- Adelie,Dream,36.2,17.3,187,3300,female,2008
97
- Adelie,Dream,40.8,18.9,208,4300,male,2008
98
- Adelie,Dream,38.1,18.6,190,3700,female,2008
99
- Adelie,Dream,40.3,18.5,196,4350,male,2008
100
- Adelie,Dream,33.1,16.1,178,2900,female,2008
101
- Adelie,Dream,43.2,18.5,192,4100,male,2008
102
- Adelie,Biscoe,35,17.9,192,3725,female,2009
103
- Adelie,Biscoe,41,20,203,4725,male,2009
104
- Adelie,Biscoe,37.7,16,183,3075,female,2009
105
- Adelie,Biscoe,37.8,20,190,4250,male,2009
106
- Adelie,Biscoe,37.9,18.6,193,2925,female,2009
107
- Adelie,Biscoe,39.7,18.9,184,3550,male,2009
108
- Adelie,Biscoe,38.6,17.2,199,3750,female,2009
109
- Adelie,Biscoe,38.2,20,190,3900,male,2009
110
- Adelie,Biscoe,38.1,17,181,3175,female,2009
111
- Adelie,Biscoe,43.2,19,197,4775,male,2009
112
- Adelie,Biscoe,38.1,16.5,198,3825,female,2009
113
- Adelie,Biscoe,45.6,20.3,191,4600,male,2009
114
- Adelie,Biscoe,39.7,17.7,193,3200,female,2009
115
- Adelie,Biscoe,42.2,19.5,197,4275,male,2009
116
- Adelie,Biscoe,39.6,20.7,191,3900,female,2009
117
- Adelie,Biscoe,42.7,18.3,196,4075,male,2009
118
- Adelie,Torgersen,38.6,17,188,2900,female,2009
119
- Adelie,Torgersen,37.3,20.5,199,3775,male,2009
120
- Adelie,Torgersen,35.7,17,189,3350,female,2009
121
- Adelie,Torgersen,41.1,18.6,189,3325,male,2009
122
- Adelie,Torgersen,36.2,17.2,187,3150,female,2009
123
- Adelie,Torgersen,37.7,19.8,198,3500,male,2009
124
- Adelie,Torgersen,40.2,17,176,3450,female,2009
125
- Adelie,Torgersen,41.4,18.5,202,3875,male,2009
126
- Adelie,Torgersen,35.2,15.9,186,3050,female,2009
127
- Adelie,Torgersen,40.6,19,199,4000,male,2009
128
- Adelie,Torgersen,38.8,17.6,191,3275,female,2009
129
- Adelie,Torgersen,41.5,18.3,195,4300,male,2009
130
- Adelie,Torgersen,39,17.1,191,3050,female,2009
131
- Adelie,Torgersen,44.1,18,210,4000,male,2009
132
- Adelie,Torgersen,38.5,17.9,190,3325,female,2009
133
- Adelie,Torgersen,43.1,19.2,197,3500,male,2009
134
- Adelie,Dream,36.8,18.5,193,3500,female,2009
135
- Adelie,Dream,37.5,18.5,199,4475,male,2009
136
- Adelie,Dream,38.1,17.6,187,3425,female,2009
137
- Adelie,Dream,41.1,17.5,190,3900,male,2009
138
- Adelie,Dream,35.6,17.5,191,3175,female,2009
139
- Adelie,Dream,40.2,20.1,200,3975,male,2009
140
- Adelie,Dream,37,16.5,185,3400,female,2009
141
- Adelie,Dream,39.7,17.9,193,4250,male,2009
142
- Adelie,Dream,40.2,17.1,193,3400,female,2009
143
- Adelie,Dream,40.6,17.2,187,3475,male,2009
144
- Adelie,Dream,32.1,15.5,188,3050,female,2009
145
- Adelie,Dream,40.7,17,190,3725,male,2009
146
- Adelie,Dream,37.3,16.8,192,3000,female,2009
147
- Adelie,Dream,39,18.7,185,3650,male,2009
148
- Adelie,Dream,39.2,18.6,190,4250,male,2009
149
- Adelie,Dream,36.6,18.4,184,3475,female,2009
150
- Adelie,Dream,36,17.8,195,3450,female,2009
151
- Adelie,Dream,37.8,18.1,193,3750,male,2009
152
- Adelie,Dream,36,17.1,187,3700,female,2009
153
- Adelie,Dream,41.5,18.5,201,4000,male,2009
154
- Gentoo,Biscoe,46.1,13.2,211,4500,female,2007
155
- Gentoo,Biscoe,50,16.3,230,5700,male,2007
156
- Gentoo,Biscoe,48.7,14.1,210,4450,female,2007
157
- Gentoo,Biscoe,50,15.2,218,5700,male,2007
158
- Gentoo,Biscoe,47.6,14.5,215,5400,male,2007
159
- Gentoo,Biscoe,46.5,13.5,210,4550,female,2007
160
- Gentoo,Biscoe,45.4,14.6,211,4800,female,2007
161
- Gentoo,Biscoe,46.7,15.3,219,5200,male,2007
162
- Gentoo,Biscoe,43.3,13.4,209,4400,female,2007
163
- Gentoo,Biscoe,46.8,15.4,215,5150,male,2007
164
- Gentoo,Biscoe,40.9,13.7,214,4650,female,2007
165
- Gentoo,Biscoe,49,16.1,216,5550,male,2007
166
- Gentoo,Biscoe,45.5,13.7,214,4650,female,2007
167
- Gentoo,Biscoe,48.4,14.6,213,5850,male,2007
168
- Gentoo,Biscoe,45.8,14.6,210,4200,female,2007
169
- Gentoo,Biscoe,49.3,15.7,217,5850,male,2007
170
- Gentoo,Biscoe,42,13.5,210,4150,female,2007
171
- Gentoo,Biscoe,49.2,15.2,221,6300,male,2007
172
- Gentoo,Biscoe,46.2,14.5,209,4800,female,2007
173
- Gentoo,Biscoe,48.7,15.1,222,5350,male,2007
174
- Gentoo,Biscoe,50.2,14.3,218,5700,male,2007
175
- Gentoo,Biscoe,45.1,14.5,215,5000,female,2007
176
- Gentoo,Biscoe,46.5,14.5,213,4400,female,2007
177
- Gentoo,Biscoe,46.3,15.8,215,5050,male,2007
178
- Gentoo,Biscoe,42.9,13.1,215,5000,female,2007
179
- Gentoo,Biscoe,46.1,15.1,215,5100,male,2007
180
- Gentoo,Biscoe,44.5,14.3,216,4100,NA,2007
181
- Gentoo,Biscoe,47.8,15,215,5650,male,2007
182
- Gentoo,Biscoe,48.2,14.3,210,4600,female,2007
183
- Gentoo,Biscoe,50,15.3,220,5550,male,2007
184
- Gentoo,Biscoe,47.3,15.3,222,5250,male,2007
185
- Gentoo,Biscoe,42.8,14.2,209,4700,female,2007
186
- Gentoo,Biscoe,45.1,14.5,207,5050,female,2007
187
- Gentoo,Biscoe,59.6,17,230,6050,male,2007
188
- Gentoo,Biscoe,49.1,14.8,220,5150,female,2008
189
- Gentoo,Biscoe,48.4,16.3,220,5400,male,2008
190
- Gentoo,Biscoe,42.6,13.7,213,4950,female,2008
191
- Gentoo,Biscoe,44.4,17.3,219,5250,male,2008
192
- Gentoo,Biscoe,44,13.6,208,4350,female,2008
193
- Gentoo,Biscoe,48.7,15.7,208,5350,male,2008
194
- Gentoo,Biscoe,42.7,13.7,208,3950,female,2008
195
- Gentoo,Biscoe,49.6,16,225,5700,male,2008
196
- Gentoo,Biscoe,45.3,13.7,210,4300,female,2008
197
- Gentoo,Biscoe,49.6,15,216,4750,male,2008
198
- Gentoo,Biscoe,50.5,15.9,222,5550,male,2008
199
- Gentoo,Biscoe,43.6,13.9,217,4900,female,2008
200
- Gentoo,Biscoe,45.5,13.9,210,4200,female,2008
201
- Gentoo,Biscoe,50.5,15.9,225,5400,male,2008
202
- Gentoo,Biscoe,44.9,13.3,213,5100,female,2008
203
- Gentoo,Biscoe,45.2,15.8,215,5300,male,2008
204
- Gentoo,Biscoe,46.6,14.2,210,4850,female,2008
205
- Gentoo,Biscoe,48.5,14.1,220,5300,male,2008
206
- Gentoo,Biscoe,45.1,14.4,210,4400,female,2008
207
- Gentoo,Biscoe,50.1,15,225,5000,male,2008
208
- Gentoo,Biscoe,46.5,14.4,217,4900,female,2008
209
- Gentoo,Biscoe,45,15.4,220,5050,male,2008
210
- Gentoo,Biscoe,43.8,13.9,208,4300,female,2008
211
- Gentoo,Biscoe,45.5,15,220,5000,male,2008
212
- Gentoo,Biscoe,43.2,14.5,208,4450,female,2008
213
- Gentoo,Biscoe,50.4,15.3,224,5550,male,2008
214
- Gentoo,Biscoe,45.3,13.8,208,4200,female,2008
215
- Gentoo,Biscoe,46.2,14.9,221,5300,male,2008
216
- Gentoo,Biscoe,45.7,13.9,214,4400,female,2008
217
- Gentoo,Biscoe,54.3,15.7,231,5650,male,2008
218
- Gentoo,Biscoe,45.8,14.2,219,4700,female,2008
219
- Gentoo,Biscoe,49.8,16.8,230,5700,male,2008
220
- Gentoo,Biscoe,46.2,14.4,214,4650,NA,2008
221
- Gentoo,Biscoe,49.5,16.2,229,5800,male,2008
222
- Gentoo,Biscoe,43.5,14.2,220,4700,female,2008
223
- Gentoo,Biscoe,50.7,15,223,5550,male,2008
224
- Gentoo,Biscoe,47.7,15,216,4750,female,2008
225
- Gentoo,Biscoe,46.4,15.6,221,5000,male,2008
226
- Gentoo,Biscoe,48.2,15.6,221,5100,male,2008
227
- Gentoo,Biscoe,46.5,14.8,217,5200,female,2008
228
- Gentoo,Biscoe,46.4,15,216,4700,female,2008
229
- Gentoo,Biscoe,48.6,16,230,5800,male,2008
230
- Gentoo,Biscoe,47.5,14.2,209,4600,female,2008
231
- Gentoo,Biscoe,51.1,16.3,220,6000,male,2008
232
- Gentoo,Biscoe,45.2,13.8,215,4750,female,2008
233
- Gentoo,Biscoe,45.2,16.4,223,5950,male,2008
234
- Gentoo,Biscoe,49.1,14.5,212,4625,female,2009
235
- Gentoo,Biscoe,52.5,15.6,221,5450,male,2009
236
- Gentoo,Biscoe,47.4,14.6,212,4725,female,2009
237
- Gentoo,Biscoe,50,15.9,224,5350,male,2009
238
- Gentoo,Biscoe,44.9,13.8,212,4750,female,2009
239
- Gentoo,Biscoe,50.8,17.3,228,5600,male,2009
240
- Gentoo,Biscoe,43.4,14.4,218,4600,female,2009
241
- Gentoo,Biscoe,51.3,14.2,218,5300,male,2009
242
- Gentoo,Biscoe,47.5,14,212,4875,female,2009
243
- Gentoo,Biscoe,52.1,17,230,5550,male,2009
244
- Gentoo,Biscoe,47.5,15,218,4950,female,2009
245
- Gentoo,Biscoe,52.2,17.1,228,5400,male,2009
246
- Gentoo,Biscoe,45.5,14.5,212,4750,female,2009
247
- Gentoo,Biscoe,49.5,16.1,224,5650,male,2009
248
- Gentoo,Biscoe,44.5,14.7,214,4850,female,2009
249
- Gentoo,Biscoe,50.8,15.7,226,5200,male,2009
250
- Gentoo,Biscoe,49.4,15.8,216,4925,male,2009
251
- Gentoo,Biscoe,46.9,14.6,222,4875,female,2009
252
- Gentoo,Biscoe,48.4,14.4,203,4625,female,2009
253
- Gentoo,Biscoe,51.1,16.5,225,5250,male,2009
254
- Gentoo,Biscoe,48.5,15,219,4850,female,2009
255
- Gentoo,Biscoe,55.9,17,228,5600,male,2009
256
- Gentoo,Biscoe,47.2,15.5,215,4975,female,2009
257
- Gentoo,Biscoe,49.1,15,228,5500,male,2009
258
- Gentoo,Biscoe,47.3,13.8,216,4725,NA,2009
259
- Gentoo,Biscoe,46.8,16.1,215,5500,male,2009
260
- Gentoo,Biscoe,41.7,14.7,210,4700,female,2009
261
- Gentoo,Biscoe,53.4,15.8,219,5500,male,2009
262
- Gentoo,Biscoe,43.3,14,208,4575,female,2009
263
- Gentoo,Biscoe,48.1,15.1,209,5500,male,2009
264
- Gentoo,Biscoe,50.5,15.2,216,5000,female,2009
265
- Gentoo,Biscoe,49.8,15.9,229,5950,male,2009
266
- Gentoo,Biscoe,43.5,15.2,213,4650,female,2009
267
- Gentoo,Biscoe,51.5,16.3,230,5500,male,2009
268
- Gentoo,Biscoe,46.2,14.1,217,4375,female,2009
269
- Gentoo,Biscoe,55.1,16,230,5850,male,2009
270
- Gentoo,Biscoe,44.5,15.7,217,4875,NA,2009
271
- Gentoo,Biscoe,48.8,16.2,222,6000,male,2009
272
- Gentoo,Biscoe,47.2,13.7,214,4925,female,2009
273
- Gentoo,Biscoe,NA,NA,NA,NA,NA,2009
274
- Gentoo,Biscoe,46.8,14.3,215,4850,female,2009
275
- Gentoo,Biscoe,50.4,15.7,222,5750,male,2009
276
- Gentoo,Biscoe,45.2,14.8,212,5200,female,2009
277
- Gentoo,Biscoe,49.9,16.1,213,5400,male,2009
278
- Chinstrap,Dream,46.5,17.9,192,3500,female,2007
279
- Chinstrap,Dream,50,19.5,196,3900,male,2007
280
- Chinstrap,Dream,51.3,19.2,193,3650,male,2007
281
- Chinstrap,Dream,45.4,18.7,188,3525,female,2007
282
- Chinstrap,Dream,52.7,19.8,197,3725,male,2007
283
- Chinstrap,Dream,45.2,17.8,198,3950,female,2007
284
- Chinstrap,Dream,46.1,18.2,178,3250,female,2007
285
- Chinstrap,Dream,51.3,18.2,197,3750,male,2007
286
- Chinstrap,Dream,46,18.9,195,4150,female,2007
287
- Chinstrap,Dream,51.3,19.9,198,3700,male,2007
288
- Chinstrap,Dream,46.6,17.8,193,3800,female,2007
289
- Chinstrap,Dream,51.7,20.3,194,3775,male,2007
290
- Chinstrap,Dream,47,17.3,185,3700,female,2007
291
- Chinstrap,Dream,52,18.1,201,4050,male,2007
292
- Chinstrap,Dream,45.9,17.1,190,3575,female,2007
293
- Chinstrap,Dream,50.5,19.6,201,4050,male,2007
294
- Chinstrap,Dream,50.3,20,197,3300,male,2007
295
- Chinstrap,Dream,58,17.8,181,3700,female,2007
296
- Chinstrap,Dream,46.4,18.6,190,3450,female,2007
297
- Chinstrap,Dream,49.2,18.2,195,4400,male,2007
298
- Chinstrap,Dream,42.4,17.3,181,3600,female,2007
299
- Chinstrap,Dream,48.5,17.5,191,3400,male,2007
300
- Chinstrap,Dream,43.2,16.6,187,2900,female,2007
301
- Chinstrap,Dream,50.6,19.4,193,3800,male,2007
302
- Chinstrap,Dream,46.7,17.9,195,3300,female,2007
303
- Chinstrap,Dream,52,19,197,4150,male,2007
304
- Chinstrap,Dream,50.5,18.4,200,3400,female,2008
305
- Chinstrap,Dream,49.5,19,200,3800,male,2008
306
- Chinstrap,Dream,46.4,17.8,191,3700,female,2008
307
- Chinstrap,Dream,52.8,20,205,4550,male,2008
308
- Chinstrap,Dream,40.9,16.6,187,3200,female,2008
309
- Chinstrap,Dream,54.2,20.8,201,4300,male,2008
310
- Chinstrap,Dream,42.5,16.7,187,3350,female,2008
311
- Chinstrap,Dream,51,18.8,203,4100,male,2008
312
- Chinstrap,Dream,49.7,18.6,195,3600,male,2008
313
- Chinstrap,Dream,47.5,16.8,199,3900,female,2008
314
- Chinstrap,Dream,47.6,18.3,195,3850,female,2008
315
- Chinstrap,Dream,52,20.7,210,4800,male,2008
316
- Chinstrap,Dream,46.9,16.6,192,2700,female,2008
317
- Chinstrap,Dream,53.5,19.9,205,4500,male,2008
318
- Chinstrap,Dream,49,19.5,210,3950,male,2008
319
- Chinstrap,Dream,46.2,17.5,187,3650,female,2008
320
- Chinstrap,Dream,50.9,19.1,196,3550,male,2008
321
- Chinstrap,Dream,45.5,17,196,3500,female,2008
322
- Chinstrap,Dream,50.9,17.9,196,3675,female,2009
323
- Chinstrap,Dream,50.8,18.5,201,4450,male,2009
324
- Chinstrap,Dream,50.1,17.9,190,3400,female,2009
325
- Chinstrap,Dream,49,19.6,212,4300,male,2009
326
- Chinstrap,Dream,51.5,18.7,187,3250,male,2009
327
- Chinstrap,Dream,49.8,17.3,198,3675,female,2009
328
- Chinstrap,Dream,48.1,16.4,199,3325,female,2009
329
- Chinstrap,Dream,51.4,19,201,3950,male,2009
330
- Chinstrap,Dream,45.7,17.3,193,3600,female,2009
331
- Chinstrap,Dream,50.7,19.7,203,4050,male,2009
332
- Chinstrap,Dream,42.5,17.3,187,3350,female,2009
333
- Chinstrap,Dream,52.2,18.8,197,3450,male,2009
334
- Chinstrap,Dream,45.2,16.6,191,3250,female,2009
335
- Chinstrap,Dream,49.3,19.9,203,4050,male,2009
336
- Chinstrap,Dream,50.2,18.8,202,3800,male,2009
337
- Chinstrap,Dream,45.6,19.4,194,3525,female,2009
338
- Chinstrap,Dream,51.9,19.5,206,3950,male,2009
339
- Chinstrap,Dream,46.8,16.5,189,3650,female,2009
340
- Chinstrap,Dream,45.7,17,195,3650,female,2009
341
- Chinstrap,Dream,55.8,19.8,207,4000,male,2009
342
- Chinstrap,Dream,43.5,18.1,202,3400,female,2009
343
- Chinstrap,Dream,49.6,18.2,193,3775,male,2009
344
- Chinstrap,Dream,50.8,19,210,4100,male,2009
345
- Chinstrap,Dream,50.2,18.7,198,3775,female,2009
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
preload.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from transformers import AutoProcessor, AutoModelForVision2Seq
2
+
3
+ processor = AutoProcessor.from_pretrained("HuggingFaceM4/idefics2-8b")
4
+ model = AutoModelForVision2Seq.from_pretrained("HuggingFaceM4/idefics2-8b")
requirements.txt CHANGED
@@ -1,4 +1,12 @@
1
- shiny==0.9.0
2
- shinyswatch==0.6.0
3
- seaborn==0.12.2
4
- matplotlib==3.7.1
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://pypi.ngc.nvidia.com
2
+ colorama
3
+ faicons
4
+ ffmpeg-python
5
+ openai
6
+ python-dotenv
7
+ shiny
8
+ nvidia-cuda-runtime-cu12
9
+ nvidia-cudnn
10
+ requests
11
+ torch
12
+ transformers
videoinput/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .ui import input_video_clip, audio_spinner
2
+ from .query import process_video
3
+
4
+ __all__ = (
5
+ "input_video_clip",
6
+ "audio_spinner",
7
+ "process_video",
8
+ )
videoinput/dist/index.css ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ av-settings-menu {
2
+ display: block;
3
+ width: min-content;
4
+ }
5
+
6
+ /* The normal treatment of .dropdown-item.active is a little too much */
7
+ av-settings-menu .dropdown-item.active,
8
+ av-settings-menu .dropdown-menu > li > a.active {
9
+ color: inherit;
10
+ background-color: inherit;
11
+ font-weight: bold;
12
+ }
videoinput/dist/index.js ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ // srcts/videoClipper.ts
4
+ var VideoClipperElement = class extends HTMLElement {
5
+ constructor() {
6
+ super();
7
+ this.chunks = [];
8
+ this.attachShadow({ mode: "open" });
9
+ this.shadowRoot.innerHTML = `
10
+ <style>
11
+ :host {
12
+ display: grid;
13
+ grid-template-rows: 1fr;
14
+ grid-template-columns: 1fr;
15
+ width: 100%;
16
+ height: min-content;
17
+ }
18
+ video {
19
+ grid-column: 1 / 2;
20
+ grid-row: 1 / 2;
21
+ width: 100%;
22
+ object-fit: cover;
23
+ background-color: var(--video-clip-bg, black);
24
+ aspect-ratio: 16 / 9;
25
+ border-radius: var(--video-clip-border-radius, var(--bs-border-radius-lg));
26
+ }
27
+ video.mirrored {
28
+ transform: scaleX(-1);
29
+ }
30
+ .panel-settings {
31
+ grid-column: 1 / 2;
32
+ grid-row: 1 / 2;
33
+ justify-self: end;
34
+ margin: 0.5em;
35
+ }
36
+ .panel-buttons {
37
+ grid-column: 1 / 2;
38
+ grid-row: 1 / 2;
39
+ justify-self: end;
40
+ align-self: end;
41
+ margin: 0.5em;
42
+ }
43
+ </style>
44
+ <video part="video" muted></video>
45
+ <div class="panel-settings">
46
+ <slot name="settings"></slot>
47
+ </div>
48
+ <div class="panel-buttons">
49
+ <slot name="recording-controls"></slot>
50
+ </div>
51
+ `;
52
+ this.video = this.shadowRoot.querySelector("video");
53
+ }
54
+ connectedCallback() {
55
+ (async () => {
56
+ const slotSettings = this.shadowRoot.querySelector(
57
+ "slot[name=settings]"
58
+ );
59
+ slotSettings.addEventListener("slotchange", async () => {
60
+ this.avSettingsMenu = slotSettings.assignedElements()[0];
61
+ await this.#initializeMediaInput();
62
+ if (this.buttonRecord) {
63
+ this.#setEnabledButton(this.buttonRecord);
64
+ }
65
+ });
66
+ const slotControls = this.shadowRoot.querySelector(
67
+ "slot[name=recording-controls]"
68
+ );
69
+ slotControls.addEventListener("slotchange", () => {
70
+ const findButton = (selector) => {
71
+ for (const el of slotControls.assignedElements()) {
72
+ if (el.matches(selector)) {
73
+ return el;
74
+ }
75
+ const sub = el.querySelector(selector);
76
+ if (sub) {
77
+ return sub;
78
+ }
79
+ }
80
+ return null;
81
+ };
82
+ this.buttonRecord = findButton(".record-button");
83
+ this.buttonStop = findButton(".stop-button");
84
+ this.#setEnabledButton();
85
+ this.buttonRecord.addEventListener("click", () => {
86
+ this.#setEnabledButton(this.buttonStop);
87
+ this._beginRecord();
88
+ });
89
+ this.buttonStop.addEventListener("click", () => {
90
+ this._endRecord();
91
+ this.#setEnabledButton(this.buttonRecord);
92
+ });
93
+ });
94
+ })().catch((err) => {
95
+ console.error(err);
96
+ });
97
+ }
98
+ disconnectedCallback() {
99
+ }
100
+ #setEnabledButton(btn) {
101
+ this.buttonRecord.style.display = btn === this.buttonRecord ? "inline-block" : "none";
102
+ this.buttonStop.style.display = btn === this.buttonStop ? "inline-block" : "none";
103
+ }
104
+ async setMediaDevices(cameraId, micId) {
105
+ if (this.cameraStream) {
106
+ this.cameraStream.getTracks().forEach((track) => track.stop());
107
+ }
108
+ this.cameraStream = await navigator.mediaDevices.getUserMedia({
109
+ video: {
110
+ deviceId: cameraId || void 0,
111
+ facingMode: "user",
112
+ aspectRatio: 16 / 9
113
+ },
114
+ audio: {
115
+ deviceId: micId || void 0
116
+ }
117
+ });
118
+ const isSelfieCam = true;
119
+ this.video.classList.toggle("mirrored", isSelfieCam);
120
+ const aspectRatio = this.cameraStream.getVideoTracks()[0].getSettings().aspectRatio;
121
+ if (aspectRatio) {
122
+ this.video.style.aspectRatio = `${aspectRatio}`;
123
+ } else {
124
+ this.video.style.aspectRatio = "";
125
+ }
126
+ this.video.srcObject = this.cameraStream;
127
+ this.video.play();
128
+ return {
129
+ cameraId: this.cameraStream.getVideoTracks()[0].getSettings().deviceId,
130
+ micId: this.cameraStream.getAudioTracks()[0].getSettings().deviceId
131
+ };
132
+ }
133
+ async #initializeMediaInput() {
134
+ const savedCamera = window.localStorage.getItem("multimodal-camera");
135
+ const savedMic = window.localStorage.getItem("multimodal-mic");
136
+ const { cameraId, micId } = await this.setMediaDevices(
137
+ savedCamera,
138
+ savedMic
139
+ );
140
+ const devices = await navigator.mediaDevices.enumerateDevices();
141
+ this.avSettingsMenu.setCameras(
142
+ devices.filter((dev) => dev.kind === "videoinput")
143
+ );
144
+ this.avSettingsMenu.setMics(
145
+ devices.filter((dev) => dev.kind === "audioinput")
146
+ );
147
+ this.avSettingsMenu.cameraId = cameraId;
148
+ this.avSettingsMenu.micId = micId;
149
+ const handleDeviceChange = async (deviceType, deviceId) => {
150
+ if (!deviceId) return;
151
+ window.localStorage.setItem(`multimodal-${deviceType}`, deviceId);
152
+ await this.setMediaDevices(
153
+ this.avSettingsMenu.cameraId,
154
+ this.avSettingsMenu.micId
155
+ );
156
+ };
157
+ this.avSettingsMenu.addEventListener("camera-change", (e) => {
158
+ handleDeviceChange("camera", this.avSettingsMenu.cameraId);
159
+ });
160
+ this.avSettingsMenu.addEventListener("mic-change", (e) => {
161
+ handleDeviceChange("mic", this.avSettingsMenu.micId);
162
+ });
163
+ }
164
+ _beginRecord() {
165
+ this.recorder = new MediaRecorder(this.cameraStream, {});
166
+ this.recorder.addEventListener("error", (e) => {
167
+ console.error("MediaRecorder error:", e.error);
168
+ });
169
+ this.recorder.addEventListener("dataavailable", (e) => {
170
+ this.chunks.push(e.data);
171
+ });
172
+ this.recorder.addEventListener("start", () => {
173
+ });
174
+ this.recorder.start();
175
+ }
176
+ _endRecord(emit = true) {
177
+ this.recorder.stop();
178
+ if (!emit) {
179
+ this.chunks = [];
180
+ } else {
181
+ setTimeout(() => {
182
+ const blob = new Blob(this.chunks, { type: this.chunks[0].type });
183
+ const event = new BlobEvent("data", {
184
+ data: blob
185
+ });
186
+ try {
187
+ this.dispatchEvent(event);
188
+ } finally {
189
+ this.chunks = [];
190
+ }
191
+ }, 0);
192
+ }
193
+ }
194
+ };
195
+ customElements.define("video-clipper", VideoClipperElement);
196
+
197
+ // srcts/avSettingsMenu.ts
198
+ var DeviceChangeEvent = class extends CustomEvent {
199
+ constructor(type, detail) {
200
+ super(type, { detail });
201
+ }
202
+ };
203
+ var AVSettingsMenuElement = class extends HTMLElement {
204
+ constructor() {
205
+ super();
206
+ this.addEventListener("click", (e) => {
207
+ if (e.target instanceof HTMLAnchorElement) {
208
+ const a = e.target;
209
+ if (a.classList.contains("camera-device-item")) {
210
+ this.cameraId = a.dataset.deviceId;
211
+ } else if (a.classList.contains("mic-device-item")) {
212
+ this.micId = a.dataset.deviceId;
213
+ }
214
+ }
215
+ });
216
+ }
217
+ #setDevices(deviceType, devices) {
218
+ const deviceEls = devices.map(
219
+ (dev) => this.#createDeviceElement(dev, `${deviceType}-device-item`)
220
+ );
221
+ const header = this.querySelector(`.${deviceType}-header`);
222
+ header.after(...deviceEls);
223
+ }
224
+ setCameras(cameras) {
225
+ this.#setDevices("camera", cameras);
226
+ }
227
+ setMics(mics) {
228
+ this.#setDevices("mic", mics);
229
+ }
230
+ get cameraId() {
231
+ return this.#getSelectedDevice("camera");
232
+ }
233
+ set cameraId(id) {
234
+ this.#setSelectedDevice("camera", id);
235
+ }
236
+ get micId() {
237
+ return this.#getSelectedDevice("mic");
238
+ }
239
+ set micId(id) {
240
+ this.#setSelectedDevice("mic", id);
241
+ }
242
+ #createDeviceElement(dev, className) {
243
+ const li = this.ownerDocument.createElement("li");
244
+ const a = li.appendChild(this.ownerDocument.createElement("a"));
245
+ a.onclick = (e) => e.preventDefault();
246
+ a.href = "#";
247
+ a.textContent = dev.label;
248
+ a.dataset.deviceId = dev.deviceId;
249
+ a.className = className;
250
+ return li;
251
+ }
252
+ #getSelectedDevice(device) {
253
+ return this.querySelector(
254
+ `a.${device}-device-item.active`
255
+ )?.dataset.deviceId ?? null;
256
+ }
257
+ #setSelectedDevice(device, id) {
258
+ this.querySelectorAll(`a.${device}-device-item.active`).forEach(
259
+ (a) => a.classList.remove("active")
260
+ );
261
+ if (id) {
262
+ this.querySelector(
263
+ `a.${device}-device-item[data-device-id="${id}"]`
264
+ ).classList.add("active");
265
+ }
266
+ this.dispatchEvent(
267
+ new DeviceChangeEvent(`${device}-change`, {
268
+ deviceId: id
269
+ })
270
+ );
271
+ }
272
+ };
273
+ customElements.define("av-settings-menu", AVSettingsMenuElement);
274
+
275
+ // srcts/audioSpinner.ts
276
+ var AudioSpinnerElement = class extends HTMLElement {
277
+ #audio;
278
+ #canvas;
279
+ #ctx2d;
280
+ #analyzer;
281
+ #dataArray;
282
+ #smoother;
283
+ constructor() {
284
+ super();
285
+ this.attachShadow({ mode: "open" });
286
+ this.shadowRoot.innerHTML = `
287
+ <style>
288
+ :host {
289
+ display: block;
290
+ position: relative;
291
+ }
292
+ ::slotted(canvas) {
293
+ position: absolute;
294
+ top: 0;
295
+ left: 0;
296
+ }
297
+ ::slotted(audio) {
298
+ display: none;
299
+ }
300
+ </style>
301
+ <slot name="audio"></slot>
302
+ <slot name="canvas"></slot>
303
+ `;
304
+ }
305
+ connectedCallback() {
306
+ const audioSlot = this.shadowRoot.querySelector(
307
+ "slot[name=audio]"
308
+ );
309
+ this.#audio = this.ownerDocument.createElement("audio");
310
+ this.#audio.autoplay = true;
311
+ this.#audio.controls = false;
312
+ this.#audio.src = this.getAttribute("src");
313
+ this.#audio.slot = "audio";
314
+ audioSlot.assign(this.#audio);
315
+ this.#audio.addEventListener("play", () => {
316
+ this.#draw();
317
+ });
318
+ this.#audio.onpause = () => {
319
+ this.style.transition = "opacity 0.5s 1s";
320
+ this.classList.add("fade");
321
+ this.addEventListener("transitionend", () => {
322
+ this.remove();
323
+ });
324
+ };
325
+ const canvasSlot = this.shadowRoot.querySelector(
326
+ "slot[name=canvas]"
327
+ );
328
+ this.#canvas = this.ownerDocument.createElement("canvas");
329
+ this.#canvas.slot = "canvas";
330
+ this.#canvas.width = this.clientWidth * window.devicePixelRatio;
331
+ this.#canvas.height = this.clientHeight * window.devicePixelRatio;
332
+ this.#canvas.style.width = this.clientWidth + "px";
333
+ this.#canvas.style.height = this.clientHeight + "px";
334
+ this.appendChild(this.#canvas);
335
+ canvasSlot.assign(this.#canvas);
336
+ this.#ctx2d = this.#canvas.getContext("2d");
337
+ this.#ctx2d.scale(window.devicePixelRatio, window.devicePixelRatio);
338
+ new ResizeObserver(() => {
339
+ this.#canvas.width = this.clientWidth;
340
+ this.#canvas.height = this.clientHeight;
341
+ }).observe(this);
342
+ const audioCtx = new AudioContext();
343
+ const source = audioCtx.createMediaElementSource(this.#audio);
344
+ this.#analyzer = new AnalyserNode(audioCtx, {
345
+ fftSize: 2048
346
+ });
347
+ this.#dataArray = new Float32Array(this.#analyzer.frequencyBinCount);
348
+ source.connect(this.#analyzer);
349
+ this.#analyzer.connect(audioCtx.destination);
350
+ const dataArray2 = new Float32Array(this.#analyzer.frequencyBinCount);
351
+ this.#smoother = new Smoother(5, (samples) => {
352
+ for (let i = 0; i < dataArray2.length; i++) {
353
+ dataArray2[i] = 0;
354
+ for (let j = 0; j < samples.length; j++) {
355
+ dataArray2[i] += samples[j][i];
356
+ }
357
+ dataArray2[i] /= samples.length;
358
+ }
359
+ return dataArray2;
360
+ });
361
+ this.#draw();
362
+ }
363
+ #draw() {
364
+ if (!this.isConnected) {
365
+ return;
366
+ }
367
+ requestAnimationFrame(() => this.#draw());
368
+ const width = this.#canvas.width;
369
+ const height = this.#canvas.height;
370
+ this.#ctx2d.clearRect(0, 0, width, height);
371
+ this.#analyzer.getFloatTimeDomainData(this.#dataArray);
372
+ const smoothed = this.#smoother.add(new Float32Array(this.#dataArray));
373
+ const {
374
+ spinVelocity,
375
+ gap,
376
+ thickness,
377
+ minRadius,
378
+ radiusFactor,
379
+ steps,
380
+ blades
381
+ } = this.#getSettings(width, height);
382
+ const avg = smoothed.reduce((a, b) => a + Math.abs(b), 0) / smoothed.length * 4;
383
+ const radius = minRadius + avg * (height - minRadius) / radiusFactor;
384
+ for (let step = 0; step < steps; step++) {
385
+ const this_radius = radius - step * (radius / (steps + 1));
386
+ if (step === steps - 1) {
387
+ this.#drawPie(width, height, 0, Math.PI * 2, this_radius, thickness);
388
+ } else {
389
+ const seconds = (/* @__PURE__ */ new Date()).getTime() / 1e3;
390
+ const startAngle = seconds * spinVelocity % (Math.PI * 2);
391
+ for (let blade = 0; blade < blades; blade++) {
392
+ const angleOffset = Math.PI * 2 / blades * blade;
393
+ const sweep = Math.PI * 2 / blades - gap;
394
+ this.#drawPie(
395
+ width,
396
+ height,
397
+ startAngle + angleOffset,
398
+ sweep,
399
+ this_radius,
400
+ thickness
401
+ );
402
+ }
403
+ }
404
+ }
405
+ }
406
+ #drawPie(width, height, startAngle, sweep, radius, thickness) {
407
+ this.#ctx2d.beginPath();
408
+ this.#ctx2d.fillStyle = this.#canvas.computedStyleMap().get("color")?.toString();
409
+ if (!thickness) {
410
+ this.#ctx2d.moveTo(width / 2, height / 2);
411
+ }
412
+ this.#ctx2d.arc(
413
+ width / 2,
414
+ height / 2,
415
+ radius,
416
+ startAngle,
417
+ startAngle + sweep
418
+ );
419
+ if (!thickness) {
420
+ this.#ctx2d.lineTo(width / 2, height / 2);
421
+ } else {
422
+ this.#ctx2d.arc(
423
+ width / 2,
424
+ height / 2,
425
+ radius - thickness,
426
+ startAngle + sweep,
427
+ startAngle,
428
+ true
429
+ );
430
+ }
431
+ this.#ctx2d.fill();
432
+ }
433
+ #getSettings(width, height) {
434
+ const settings = {
435
+ spinVelocity: 5,
436
+ gap: Math.PI / 5,
437
+ thickness: 2.5,
438
+ minRadius: Math.min(width, height) / 6,
439
+ radiusFactor: 1.8,
440
+ steps: 3,
441
+ blades: 3
442
+ };
443
+ for (const key in settings) {
444
+ const value = tryParseFloat(this.dataset[key]);
445
+ if (typeof value !== "undefined") {
446
+ Object.assign(settings, { [key]: value });
447
+ }
448
+ }
449
+ return settings;
450
+ }
451
+ };
452
+ window.customElements.define("audio-spinner", AudioSpinnerElement);
453
+ var Smoother = class {
454
+ #samples = [];
455
+ #smooth;
456
+ #size;
457
+ #pos;
458
+ constructor(size, smooth) {
459
+ this.#size = size;
460
+ this.#pos = 0;
461
+ this.#smooth = smooth;
462
+ }
463
+ add(sample) {
464
+ this.#samples[this.#pos] = sample;
465
+ this.#pos = (this.#pos + 1) % this.#size;
466
+ return this.smoothed();
467
+ }
468
+ smoothed() {
469
+ return this.#smooth(this.#samples);
470
+ }
471
+ };
472
+ function tryParseFloat(str) {
473
+ if (typeof str === "undefined") {
474
+ return void 0;
475
+ }
476
+ const parsed = parseFloat(str);
477
+ return isNaN(parsed) ? void 0 : parsed;
478
+ }
479
+
480
+ // srcts/index.ts
481
+ var VideoClipperBinding = class extends Shiny.InputBinding {
482
+ #lastKnownValue = /* @__PURE__ */ new WeakMap();
483
+ #handlers = /* @__PURE__ */ new WeakMap();
484
+ find(scope) {
485
+ return $(scope).find("video-clipper");
486
+ }
487
+ getValue(el) {
488
+ return this.#lastKnownValue.get(el);
489
+ }
490
+ subscribe(el, callback) {
491
+ const handler = async (ev) => {
492
+ const blob = ev.data;
493
+ this.#lastKnownValue.set(el, {
494
+ type: blob.type,
495
+ bytes: await base64(blob)
496
+ });
497
+ callback(true);
498
+ };
499
+ el.addEventListener("data", handler);
500
+ this.#handlers.set(el, handler);
501
+ }
502
+ unsubscribe(el) {
503
+ const handler = this.#handlers.get(el);
504
+ el.removeEventListener("data", handler);
505
+ this.#handlers.delete(el);
506
+ }
507
+ };
508
+ window.Shiny.inputBindings.register(new VideoClipperBinding(), "video-clipper");
509
+ async function base64(blob) {
510
+ const buf = await blob.arrayBuffer();
511
+ const results = [];
512
+ const CHUNKSIZE = 1024;
513
+ for (let i = 0; i < buf.byteLength; i += CHUNKSIZE) {
514
+ const chunk = buf.slice(i, i + CHUNKSIZE);
515
+ results.push(String.fromCharCode(...new Uint8Array(chunk)));
516
+ }
517
+ return btoa(results.join(""));
518
+ }
videoinput/hf.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import torch
3
+ from PIL import Image
4
+ from io import BytesIO
5
+
6
+ from transformers import AutoProcessor, AutoModelForVision2Seq
7
+ from transformers.image_utils import load_image
8
+
9
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
10
+
11
+ image1 = load_image(
12
+ "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg"
13
+ )
14
+
15
+ processor = AutoProcessor.from_pretrained("HuggingFaceM4/idefics2-8b")
16
+ model = AutoModelForVision2Seq.from_pretrained("HuggingFaceM4/idefics2-8b").to(DEVICE)
videoinput/input.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path, PurePath
4
+
5
+ import ffmpeg
6
+
7
+
8
+ class DecodedInput:
9
+ audio: PurePath
10
+ images: tuple[PurePath, ...]
11
+
12
+ def __init__(
13
+ self,
14
+ audio: PurePath,
15
+ images: tuple[PurePath, ...],
16
+ tmpdir: tempfile.TemporaryDirectory,
17
+ ):
18
+ self.audio = audio
19
+ self.images = images
20
+ self.tmpdir = tmpdir
21
+
22
+ def __enter__(self):
23
+ return self
24
+
25
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
26
+ print("Cleaning up " + self.tmpdir.name)
27
+ self.tmpdir.cleanup()
28
+
29
+
30
+ def decode_input(input_path: PurePath, fps: int = 2) -> DecodedInput:
31
+ outdir = tempfile.TemporaryDirectory()
32
+ audio = PurePath(outdir.name) / "audio.mp3"
33
+ (
34
+ ffmpeg.input(
35
+ str(input_path),
36
+ )
37
+ .output(
38
+ str(audio),
39
+ loglevel="error",
40
+ **{
41
+ # Use 64k bitrate for smaller file
42
+ "b:a": "64k",
43
+ # Only output one channel, again for smaller file
44
+ "ac": "1",
45
+ },
46
+ )
47
+ .run()
48
+ )
49
+ (
50
+ ffmpeg.input(str(input_path))
51
+ .output(
52
+ str(PurePath(outdir.name) / "frame-%04d.jpg"),
53
+ loglevel="error",
54
+ **{
55
+ # Use fps as specified, scale image to fit within 512x512
56
+ "vf": f"fps={fps},scale='if(gt(iw,ih),512,-1)':'if(gt(ih,iw),512,-1)'",
57
+ "q:v": "20",
58
+ },
59
+ )
60
+ .run()
61
+ )
62
+ images = list(Path(outdir.name).glob("*.jpg"))
63
+ images.sort()
64
+ return DecodedInput(audio, tuple(images), outdir)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ with decode_input(PurePath("data/question.mov")) as input:
69
+ print(input.audio)
70
+ print(input.images)
videoinput/package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "multimodal",
3
+ "version": "1.0.0",
4
+ "description": "This is a simple script that takes a video file as input, and turns it into text plus images in a form GPT-4o can understand. The response from the model is then read aloud.",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "esbuild srcts/index.ts --bundle --platform=node --outfile=dist/index.js",
9
+ "watch": "esbuild srcts/index.ts --bundle --platform=node --outfile=dist/index.js --watch",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/jcheng5/multimodal.git"
15
+ },
16
+ "author": "",
17
+ "license": "MIT",
18
+ "bugs": {
19
+ "url": "https://github.com/jcheng5/multimodal/issues"
20
+ },
21
+ "homepage": "https://github.com/jcheng5/multimodal#readme",
22
+ "dependencies": {},
23
+ "devDependencies": {
24
+ "@types/jquery": "^3.5.30",
25
+ "@types/rstudio-shiny": "https://github.com/rstudio/shiny#v1.7.0",
26
+ "esbuild": "^0.21.3",
27
+ "typescript": "^5.4.5"
28
+ }
29
+ }
videoinput/query.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Callable, Optional
2
+
3
+ import dotenv
4
+ from openai import AsyncOpenAI
5
+
6
+ from .input import decode_input
7
+ from .utils import NamedTemporaryFile, file_to_data_uri, timed
8
+
9
+ # Load OpenAI API key from .env file
10
+ dotenv.load_dotenv()
11
+
12
+ TERSE_PROMPT = """
13
+ The user you're responding to is EXTREMELY busy and cannot waste a single
14
+ second. Above all, answers must be as concise as possible. Every wasted word
15
+ will result in a huge deduction of points. In fact, use the absolute minimum
16
+ number of words while still technically answering the question. Avoid
17
+ adjectives, adverbs, fill words, and qualifiers.
18
+ """
19
+
20
+ EXTRA_TERSE_PROMPT = """
21
+ Definitely don't restate any part of the question in your answer, if it can
22
+ possibly be avoided. Don't speak in complete sentences. Just get to the point as
23
+ quickly as possible.
24
+ """
25
+
26
+ SUBJECT_PROMPT = """
27
+ If the user refers to "I" or "me" in the text input, you should assume that's
28
+ referring to the most prominent person in the video.
29
+
30
+ If the user refers to "you" in the text input, you should assume that's
31
+ referring to you, the AI model.
32
+ """
33
+
34
+ VIDEO_PROMPT = """
35
+ The images are frames of a video at 2 frames per second. The user doesn't know
36
+ the video is split into frames, so make sure your video refers to these images
37
+ collectively as "the video", not "the images" or "the video frames".
38
+ """
39
+
40
+ SPEAKING_PROMPT = """
41
+ The user is asking you to speak the answer. Make sure your response is in the
42
+ form of a friendly, casual spoken answer, not a formal written one.
43
+ """
44
+
45
+ SYSTEM_PROMPT = (
46
+ VIDEO_PROMPT
47
+ + SUBJECT_PROMPT
48
+ + SPEAKING_PROMPT
49
+ # + TERSE_PROMPT
50
+ # + EXTRA_TERSE_PROMPT
51
+ )
52
+
53
+
54
+ async def process_video(
55
+ client: AsyncOpenAI, filepath: str, callback: Optional[Callable[[str], None]]
56
+ ) -> None:
57
+ if callback is None:
58
+ callback = lambda _: None
59
+
60
+ callback("Decoding input")
61
+ input = decode_input(filepath, fps=2)
62
+
63
+ with input:
64
+ callback("Decoding speech")
65
+ with open(str(input.audio), "rb") as audio_file:
66
+ transcription = await client.audio.transcriptions.create(
67
+ model="whisper-1", file=audio_file
68
+ )
69
+
70
+ callback("Processing video")
71
+ images = [file_to_data_uri(filename, "image/jpeg") for filename in input.images]
72
+
73
+ callback("Querying")
74
+ response = await client.chat.completions.create(
75
+ model="gpt-4o",
76
+ messages=[
77
+ {
78
+ "role": "user",
79
+ "content": [
80
+ {"type": "text", "text": transcription.text},
81
+ *[
82
+ {
83
+ "type": "image_url",
84
+ "image_url": {"url": image, "detail": "auto"},
85
+ }
86
+ for image in images
87
+ ],
88
+ ],
89
+ },
90
+ {
91
+ "role": "system",
92
+ "content": [
93
+ {
94
+ "type": "text",
95
+ "text": SYSTEM_PROMPT,
96
+ }
97
+ ],
98
+ },
99
+ ],
100
+ )
101
+
102
+ callback("Converting to speech")
103
+ audio = await client.audio.speech.create(
104
+ model="tts-1",
105
+ voice="nova",
106
+ input=response.choices[0].message.content,
107
+ response_format="mp3",
108
+ )
109
+
110
+ callback("Encoding audio")
111
+ with NamedTemporaryFile(suffix=".mp3", delete_on_close=False) as file:
112
+ file.close()
113
+ audio.write_to_file(file.name)
114
+ return file_to_data_uri(file.name, "audio/mpeg")
videoinput/srcts/audioSpinner.ts ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class AudioSpinnerElement extends HTMLElement {
2
+ #audio!: HTMLAudioElement;
3
+ #canvas!: HTMLCanvasElement;
4
+ #ctx2d!: CanvasRenderingContext2D;
5
+ #analyzer!: AnalyserNode;
6
+ #dataArray!: Float32Array;
7
+ #smoother!: Smoother<Float32Array>;
8
+
9
+ constructor() {
10
+ super();
11
+ this.attachShadow({ mode: "open" });
12
+ this.shadowRoot!.innerHTML = `
13
+ <style>
14
+ :host {
15
+ display: block;
16
+ position: relative;
17
+ }
18
+ ::slotted(canvas) {
19
+ position: absolute;
20
+ top: 0;
21
+ left: 0;
22
+ }
23
+ ::slotted(audio) {
24
+ display: none;
25
+ }
26
+ </style>
27
+ <slot name="audio"></slot>
28
+ <slot name="canvas"></slot>
29
+ `;
30
+ }
31
+
32
+ connectedCallback() {
33
+ // Create <audio>. This will play the sound.
34
+ const audioSlot = this.shadowRoot!.querySelector(
35
+ "slot[name=audio]"
36
+ )! as HTMLSlotElement;
37
+ this.#audio = this.ownerDocument.createElement("audio");
38
+ this.#audio.autoplay = true;
39
+ this.#audio.controls = false;
40
+ this.#audio.src = this.getAttribute("src")!;
41
+ this.#audio.slot = "audio";
42
+ audioSlot.assign(this.#audio);
43
+ this.#audio.addEventListener("play", () => {
44
+ this.#draw();
45
+ });
46
+ this.#audio.onpause = () => {
47
+ this.style.transition = "opacity 0.5s 1s";
48
+ this.classList.add("fade");
49
+ this.addEventListener("transitionend", () => {
50
+ this.remove();
51
+ });
52
+ };
53
+
54
+ // Create <canvas>. This will be the target of our vizualization.
55
+ const canvasSlot = this.shadowRoot!.querySelector(
56
+ "slot[name=canvas]"
57
+ )! as HTMLSlotElement;
58
+ this.#canvas = this.ownerDocument.createElement("canvas");
59
+ this.#canvas.slot = "canvas";
60
+ this.#canvas.width = this.clientWidth * window.devicePixelRatio;
61
+ this.#canvas.height = this.clientHeight * window.devicePixelRatio;
62
+ this.#canvas.style.width = this.clientWidth + "px";
63
+ this.#canvas.style.height = this.clientHeight + "px";
64
+ this.appendChild(this.#canvas);
65
+ canvasSlot.assign(this.#canvas);
66
+ this.#ctx2d = this.#canvas.getContext("2d")!;
67
+ this.#ctx2d.scale(window.devicePixelRatio, window.devicePixelRatio);
68
+ new ResizeObserver(() => {
69
+ this.#canvas.width = this.clientWidth;
70
+ this.#canvas.height = this.clientHeight;
71
+ }).observe(this);
72
+
73
+ // Initialize analyzer
74
+ const audioCtx = new AudioContext();
75
+ const source = audioCtx.createMediaElementSource(this.#audio);
76
+ this.#analyzer = new AnalyserNode(audioCtx, {
77
+ fftSize: 2048,
78
+ });
79
+ this.#dataArray = new Float32Array(this.#analyzer.frequencyBinCount);
80
+ source.connect(this.#analyzer);
81
+ this.#analyzer.connect(audioCtx.destination);
82
+
83
+ // Initialize persistent data structures needed for vizualization
84
+ const dataArray2 = new Float32Array(this.#analyzer.frequencyBinCount);
85
+ this.#smoother = new Smoother<Float32Array>(5, (samples) => {
86
+ for (let i = 0; i < dataArray2.length; i++) {
87
+ dataArray2[i] = 0;
88
+ for (let j = 0; j < samples.length; j++) {
89
+ dataArray2[i] += samples[j][i];
90
+ }
91
+ dataArray2[i] /= samples.length;
92
+ }
93
+ return dataArray2;
94
+ });
95
+
96
+ this.#draw();
97
+ }
98
+
99
+ #draw() {
100
+ if (!this.isConnected) {
101
+ return;
102
+ }
103
+
104
+ requestAnimationFrame(() => this.#draw());
105
+
106
+ const width = this.#canvas.width;
107
+ const height = this.#canvas.height;
108
+ this.#ctx2d.clearRect(0, 0, width, height);
109
+
110
+ this.#analyzer.getFloatTimeDomainData(this.#dataArray);
111
+ const smoothed = this.#smoother.add(new Float32Array(this.#dataArray));
112
+
113
+ const {
114
+ spinVelocity,
115
+ gap,
116
+ thickness,
117
+ minRadius,
118
+ radiusFactor,
119
+ steps,
120
+ blades,
121
+ } = this.#getSettings(width, height);
122
+
123
+ const avg =
124
+ (smoothed.reduce((a, b) => a + Math.abs(b), 0) / smoothed.length) * 4;
125
+
126
+ const radius = minRadius + (avg * (height - minRadius)) / radiusFactor;
127
+ for (let step = 0; step < steps; step++) {
128
+ const this_radius = radius - step * (radius / (steps + 1));
129
+ if (step === steps - 1) {
130
+ this.#drawPie(width, height, 0, Math.PI * 2, this_radius, thickness);
131
+ } else {
132
+ const seconds = new Date().getTime() / 1000;
133
+ const startAngle = (seconds * spinVelocity) % (Math.PI * 2);
134
+ for (let blade = 0; blade < blades; blade++) {
135
+ const angleOffset = ((Math.PI * 2) / blades) * blade;
136
+ const sweep = (Math.PI * 2) / blades - gap;
137
+ this.#drawPie(
138
+ width,
139
+ height,
140
+ startAngle + angleOffset,
141
+ sweep,
142
+ this_radius,
143
+ thickness
144
+ );
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ #drawPie(
151
+ width: number,
152
+ height: number,
153
+ startAngle: number,
154
+ sweep: number,
155
+ radius: number,
156
+ thickness?: number
157
+ ) {
158
+ this.#ctx2d.beginPath();
159
+ this.#ctx2d.fillStyle = this.#canvas
160
+ .computedStyleMap()
161
+ .get("color")
162
+ ?.toString()!;
163
+ if (!thickness) {
164
+ this.#ctx2d.moveTo(width / 2, height / 2);
165
+ }
166
+ this.#ctx2d.arc(
167
+ width / 2,
168
+ height / 2,
169
+ radius,
170
+ startAngle,
171
+ startAngle + sweep
172
+ );
173
+ if (!thickness) {
174
+ this.#ctx2d.lineTo(width / 2, height / 2);
175
+ } else {
176
+ this.#ctx2d.arc(
177
+ width / 2,
178
+ height / 2,
179
+ radius - thickness,
180
+ startAngle + sweep,
181
+ startAngle,
182
+ true
183
+ );
184
+ }
185
+ this.#ctx2d.fill();
186
+ }
187
+
188
+ #getSettings(width: number, height: number) {
189
+ // Visualization settings
190
+ const settings = {
191
+ spinVelocity: 5,
192
+ gap: Math.PI / 5,
193
+ thickness: 2.5,
194
+ minRadius: Math.min(width, height) / 6,
195
+ radiusFactor: 1.8,
196
+ steps: 3,
197
+ blades: 3,
198
+ };
199
+ for (const key in settings) {
200
+ const value = tryParseFloat(this.dataset[key]);
201
+ if (typeof value !== "undefined") {
202
+ Object.assign(settings, { [key]: value });
203
+ }
204
+ }
205
+ return settings;
206
+ }
207
+ }
208
+
209
+ window.customElements.define("audio-spinner", AudioSpinnerElement);
210
+
211
+ class Smoother<T> {
212
+ #samples: T[] = [];
213
+ #smooth: (samples: T[]) => T;
214
+ #size: number;
215
+ #pos: number;
216
+
217
+ constructor(size: number, smooth: (samples: T[]) => T) {
218
+ this.#size = size;
219
+ this.#pos = 0;
220
+ this.#smooth = smooth;
221
+ }
222
+
223
+ add(sample: T): T {
224
+ this.#samples[this.#pos] = sample;
225
+ this.#pos = (this.#pos + 1) % this.#size;
226
+ return this.smoothed();
227
+ }
228
+
229
+ smoothed(): T {
230
+ return this.#smooth(this.#samples);
231
+ }
232
+ }
233
+
234
+ function tryParseFloat(str?: string): number | undefined {
235
+ if (typeof str === "undefined") {
236
+ return undefined;
237
+ }
238
+ const parsed = parseFloat(str);
239
+ return isNaN(parsed) ? undefined : parsed;
240
+ }
videoinput/srcts/avSettingsMenu.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class DeviceChangeEvent extends CustomEvent<{ deviceId: string | null }> {
2
+ constructor(type: string, detail: { deviceId: string | null }) {
3
+ super(type, { detail });
4
+ }
5
+ }
6
+
7
+ class AVSettingsMenuElement extends HTMLElement {
8
+ constructor() {
9
+ super();
10
+ this.addEventListener("click", (e) => {
11
+ if (e.target instanceof HTMLAnchorElement) {
12
+ const a = e.target;
13
+ if (a.classList.contains("camera-device-item")) {
14
+ this.cameraId = a.dataset.deviceId!;
15
+ } else if (a.classList.contains("mic-device-item")) {
16
+ this.micId = a.dataset.deviceId!;
17
+ }
18
+ }
19
+ });
20
+ }
21
+
22
+ #setDevices(deviceType: "camera" | "mic", devices: MediaDeviceInfo[]) {
23
+ const deviceEls = devices.map((dev) =>
24
+ this.#createDeviceElement(dev, `${deviceType}-device-item`)
25
+ );
26
+ const header = this.querySelector(`.${deviceType}-header`)!;
27
+ header.after(...deviceEls);
28
+ }
29
+
30
+ setCameras(cameras: MediaDeviceInfo[]) {
31
+ this.#setDevices("camera", cameras);
32
+ }
33
+
34
+ setMics(mics: MediaDeviceInfo[]) {
35
+ this.#setDevices("mic", mics);
36
+ }
37
+
38
+ get cameraId(): string | null {
39
+ return this.#getSelectedDevice("camera");
40
+ }
41
+
42
+ set cameraId(id: string | null) {
43
+ this.#setSelectedDevice("camera", id);
44
+ }
45
+
46
+ get micId(): string | null {
47
+ return this.#getSelectedDevice("mic");
48
+ }
49
+
50
+ set micId(id: string | null) {
51
+ this.#setSelectedDevice("mic", id);
52
+ }
53
+
54
+ #createDeviceElement(dev: MediaDeviceInfo, className: string): HTMLLIElement {
55
+ const li = this.ownerDocument.createElement("li");
56
+ const a = li.appendChild(this.ownerDocument.createElement("a"));
57
+ a.onclick = (e) => e.preventDefault();
58
+ a.href = "#";
59
+ a.textContent = dev.label;
60
+ a.dataset.deviceId = dev.deviceId!;
61
+ a.className = className;
62
+ return li;
63
+ }
64
+
65
+ #getSelectedDevice(device: "camera" | "mic"): string | null {
66
+ return (
67
+ (
68
+ this.querySelector(
69
+ `a.${device}-device-item.active`
70
+ ) as HTMLAnchorElement
71
+ )?.dataset.deviceId ?? null
72
+ );
73
+ }
74
+
75
+ #setSelectedDevice(device: "camera" | "mic", id: string | null) {
76
+ this.querySelectorAll(`a.${device}-device-item.active`).forEach((a) =>
77
+ a.classList.remove("active")
78
+ );
79
+ if (id) {
80
+ this.querySelector(
81
+ `a.${device}-device-item[data-device-id="${id}"]`
82
+ )!.classList.add("active");
83
+ }
84
+ this.dispatchEvent(
85
+ new DeviceChangeEvent(`${device}-change`, {
86
+ deviceId: id,
87
+ })
88
+ );
89
+ }
90
+ }
91
+ customElements.define("av-settings-menu", AVSettingsMenuElement);
videoinput/srcts/index.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BindScope } from "rstudio-shiny/srcts/types/src/shiny/bind";
2
+
3
+ // Register custom elements
4
+ import "./videoClipper";
5
+ import "./avSettingsMenu";
6
+ import "./audioSpinner";
7
+
8
+ // Create input binding to send video clips from <video-clipper> to Shiny
9
+ class VideoClipperBinding extends Shiny.InputBinding {
10
+ #lastKnownValue = new WeakMap<HTMLElement, unknown>();
11
+ #handlers = new WeakMap<HTMLElement, (ev: Event) => Promise<void>>();
12
+
13
+ find(scope: BindScope): JQuery<HTMLElement> {
14
+ return $(scope).find("video-clipper");
15
+ }
16
+
17
+ getValue(el: HTMLElement): unknown {
18
+ return this.#lastKnownValue.get(el);
19
+ }
20
+
21
+ subscribe(el: HTMLElement, callback: (value: boolean) => void): void {
22
+ const handler = async (ev: Event) => {
23
+ const blob = (ev as BlobEvent).data;
24
+ this.#lastKnownValue.set(el, {
25
+ type: blob.type,
26
+ bytes: await base64(blob),
27
+ });
28
+ callback(true);
29
+ };
30
+ el.addEventListener("data", handler);
31
+ this.#handlers.set(el, handler);
32
+ }
33
+
34
+ unsubscribe(el: HTMLElement): void {
35
+ const handler = this.#handlers.get(el)!;
36
+ el.removeEventListener("data", handler);
37
+ this.#handlers.delete(el);
38
+ }
39
+ }
40
+
41
+ window.Shiny.inputBindings.register(new VideoClipperBinding(), "video-clipper");
42
+
43
+ /**
44
+ * Encode a Blob as a base64 string
45
+ * @param blob The Blob to encode
46
+ * @returns A base64-encoded string
47
+ */
48
+ async function base64(blob: Blob): Promise<string> {
49
+ const buf = await blob.arrayBuffer();
50
+ const results = [];
51
+ const CHUNKSIZE = 1024;
52
+ for (let i = 0; i < buf.byteLength; i += CHUNKSIZE) {
53
+ const chunk = buf.slice(i, i + CHUNKSIZE);
54
+ results.push(String.fromCharCode(...new Uint8Array(chunk)));
55
+ }
56
+ return btoa(results.join(""));
57
+ }
videoinput/srcts/videoClipper.ts ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class VideoClipperElement extends HTMLElement {
2
+ video: HTMLVideoElement;
3
+ avSettingsMenu!: AVSettingsMenuElement;
4
+ buttonRecord!: HTMLButtonElement;
5
+ buttonStop!: HTMLButtonElement;
6
+
7
+ cameraStream?: MediaStream;
8
+ micStream?: MediaStream;
9
+
10
+ recorder?: MediaRecorder;
11
+ chunks: Blob[] = [];
12
+
13
+ constructor() {
14
+ super();
15
+ this.attachShadow({ mode: "open" });
16
+ this.shadowRoot!.innerHTML = `
17
+ <style>
18
+ :host {
19
+ display: grid;
20
+ grid-template-rows: 1fr;
21
+ grid-template-columns: 1fr;
22
+ width: 100%;
23
+ height: min-content;
24
+ }
25
+ video {
26
+ grid-column: 1 / 2;
27
+ grid-row: 1 / 2;
28
+ width: 100%;
29
+ object-fit: cover;
30
+ background-color: var(--video-clip-bg, black);
31
+ aspect-ratio: 16 / 9;
32
+ border-radius: var(--video-clip-border-radius, var(--bs-border-radius-lg));
33
+ }
34
+ video.mirrored {
35
+ transform: scaleX(-1);
36
+ }
37
+ .panel-settings {
38
+ grid-column: 1 / 2;
39
+ grid-row: 1 / 2;
40
+ justify-self: end;
41
+ margin: 0.5em;
42
+ }
43
+ .panel-buttons {
44
+ grid-column: 1 / 2;
45
+ grid-row: 1 / 2;
46
+ justify-self: end;
47
+ align-self: end;
48
+ margin: 0.5em;
49
+ }
50
+ </style>
51
+ <video part="video" muted></video>
52
+ <div class="panel-settings">
53
+ <slot name="settings"></slot>
54
+ </div>
55
+ <div class="panel-buttons">
56
+ <slot name="recording-controls"></slot>
57
+ </div>
58
+ `;
59
+ this.video = this.shadowRoot!.querySelector("video")!;
60
+ }
61
+ connectedCallback() {
62
+ (async () => {
63
+ const slotSettings = this.shadowRoot!.querySelector(
64
+ "slot[name=settings]"
65
+ )! as HTMLSlotElement;
66
+ slotSettings.addEventListener("slotchange", async () => {
67
+ this.avSettingsMenu =
68
+ slotSettings.assignedElements()[0] as AVSettingsMenuElement;
69
+ await this.#initializeMediaInput();
70
+ if (this.buttonRecord) {
71
+ this.#setEnabledButton(this.buttonRecord);
72
+ }
73
+ });
74
+
75
+ const slotControls = this.shadowRoot!.querySelector(
76
+ "slot[name=recording-controls]"
77
+ )! as HTMLSlotElement;
78
+ slotControls.addEventListener("slotchange", () => {
79
+ const findButton = (selector: string): HTMLElement | null => {
80
+ for (const el of slotControls.assignedElements()) {
81
+ if (el.matches(selector)) {
82
+ return el as HTMLElement;
83
+ }
84
+ const sub = el.querySelector(selector);
85
+ if (sub) {
86
+ return sub as HTMLElement;
87
+ }
88
+ }
89
+ return null;
90
+ };
91
+ this.buttonRecord = findButton(".record-button")! as HTMLButtonElement;
92
+ this.buttonStop = findButton(".stop-button")! as HTMLButtonElement;
93
+
94
+ this.#setEnabledButton();
95
+
96
+ this.buttonRecord.addEventListener("click", () => {
97
+ this.#setEnabledButton(this.buttonStop);
98
+ this._beginRecord();
99
+ });
100
+ this.buttonStop.addEventListener("click", () => {
101
+ this._endRecord();
102
+ this.#setEnabledButton(this.buttonRecord);
103
+ });
104
+ });
105
+ })().catch((err) => {
106
+ console.error(err);
107
+ });
108
+ }
109
+
110
+ disconnectedCallback() {}
111
+
112
+ #setEnabledButton(btn?: HTMLButtonElement) {
113
+ this.buttonRecord.style.display =
114
+ btn === this.buttonRecord ? "inline-block" : "none";
115
+ this.buttonStop.style.display =
116
+ btn === this.buttonStop ? "inline-block" : "none";
117
+ }
118
+
119
+ async setMediaDevices(
120
+ cameraId: string | null,
121
+ micId: string | null
122
+ ): Promise<{ cameraId: string; micId: string }> {
123
+ if (this.cameraStream) {
124
+ this.cameraStream.getTracks().forEach((track) => track.stop());
125
+ }
126
+
127
+ this.cameraStream = await navigator.mediaDevices.getUserMedia({
128
+ video: {
129
+ deviceId: cameraId || undefined,
130
+ facingMode: "user",
131
+ aspectRatio: 16 / 9,
132
+ },
133
+ audio: {
134
+ deviceId: micId || undefined,
135
+ },
136
+ });
137
+
138
+ // TODO: I can't figure out how to tell if this is actually a selfie cam.
139
+ // Ideally we wouldn't mirror unless we are sure.
140
+ const isSelfieCam = true; // this.cameraStream.getVideoTracks()[0].getSettings().facingMode === "user";
141
+ this.video.classList.toggle("mirrored", isSelfieCam);
142
+
143
+ /* Prevent the height from jumping around when switching cameras */
144
+ const aspectRatio = this.cameraStream
145
+ .getVideoTracks()[0]
146
+ .getSettings().aspectRatio;
147
+ if (aspectRatio) {
148
+ this.video.style.aspectRatio = `${aspectRatio}`;
149
+ } else {
150
+ this.video.style.aspectRatio = "";
151
+ }
152
+ this.video.srcObject = this.cameraStream!;
153
+ this.video.play();
154
+
155
+ return {
156
+ cameraId: this.cameraStream.getVideoTracks()[0].getSettings().deviceId!,
157
+ micId: this.cameraStream.getAudioTracks()[0].getSettings().deviceId!,
158
+ };
159
+ }
160
+
161
+ async #initializeMediaInput() {
162
+ // Retrieve the user's previous camera and mic settings, if they ever
163
+ // explicitly chose one
164
+ const savedCamera = window.localStorage.getItem("multimodal-camera");
165
+ const savedMic = window.localStorage.getItem("multimodal-mic");
166
+
167
+ // Initialize the camera and mic with the saved settings. It's important to
168
+ // request camera/mic access _before_ we attempt to enumerate devices,
169
+ // because if the user has not granted camera/mic access, enumerateDevices()
170
+ // will not prompt the user for permission and will instead return empty
171
+ // devices.
172
+ //
173
+ // The return values are the actual camera and mic IDs that were used, which
174
+ // may be different from the saved values if those devices are no longer
175
+ // available.
176
+ const { cameraId, micId } = await this.setMediaDevices(
177
+ savedCamera,
178
+ savedMic
179
+ );
180
+
181
+ // Populate the camera and mic dropdowns with the available devices
182
+ const devices = await navigator.mediaDevices.enumerateDevices();
183
+ this.avSettingsMenu.setCameras(
184
+ devices.filter((dev) => dev.kind === "videoinput")
185
+ );
186
+ this.avSettingsMenu.setMics(
187
+ devices.filter((dev) => dev.kind === "audioinput")
188
+ );
189
+
190
+ // Update the dropdown UI to reflect the actual devices that were used
191
+ this.avSettingsMenu.cameraId = cameraId;
192
+ this.avSettingsMenu.micId = micId;
193
+
194
+ // Listen for changes to the camera and mic dropdowns
195
+ const handleDeviceChange = async (
196
+ deviceType: string,
197
+ deviceId: string | null
198
+ ) => {
199
+ if (!deviceId) return;
200
+ window.localStorage.setItem(`multimodal-${deviceType}`, deviceId);
201
+ await this.setMediaDevices(
202
+ this.avSettingsMenu.cameraId,
203
+ this.avSettingsMenu.micId
204
+ );
205
+ };
206
+ this.avSettingsMenu.addEventListener("camera-change", (e) => {
207
+ handleDeviceChange("camera", this.avSettingsMenu.cameraId);
208
+ });
209
+ this.avSettingsMenu.addEventListener("mic-change", (e) => {
210
+ handleDeviceChange("mic", this.avSettingsMenu.micId);
211
+ });
212
+ }
213
+
214
+ _beginRecord() {
215
+ // Create a MediaRecorder object
216
+ this.recorder = new MediaRecorder(this.cameraStream!, {});
217
+
218
+ this.recorder.addEventListener("error", (e) => {
219
+ console.error("MediaRecorder error:", (e as ErrorEvent).error);
220
+ });
221
+ this.recorder.addEventListener("dataavailable", (e) => {
222
+ // console.log("chunk: ", e.data.size, e.data.type);
223
+ this.chunks.push(e.data);
224
+ });
225
+ this.recorder.addEventListener("start", () => {
226
+ // console.log("Recording started");
227
+ });
228
+ this.recorder.start();
229
+ }
230
+
231
+ _endRecord(emit: boolean = true) {
232
+ this.recorder!.stop();
233
+ if (!emit) {
234
+ this.chunks = [];
235
+ } else {
236
+ // Use setTimeout to give it a moment to finish processing the last chunk
237
+ setTimeout(() => {
238
+ // console.log("chunks: ", this.chunks.length);
239
+ const blob = new Blob(this.chunks, { type: this.chunks[0].type });
240
+
241
+ // emit blobevent
242
+ const event = new BlobEvent("data", {
243
+ data: blob,
244
+ });
245
+ try {
246
+ this.dispatchEvent(event);
247
+ } finally {
248
+ this.chunks = [];
249
+ }
250
+ }, 0);
251
+ }
252
+ }
253
+ }
254
+ customElements.define("video-clipper", VideoClipperElement);
videoinput/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist",
4
+ "rootDir": "./srcts",
5
+ "target": "es2020",
6
+ "module": "commonjs",
7
+ "lib": ["dom", "es2020"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true
12
+ },
13
+ "include": ["srcts/**/*"]
14
+ }
videoinput/ui.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from pathlib import Path
3
+
4
+ from faicons import icon_svg
5
+ from htmltools import HTMLDependency
6
+ from shiny import module, ui
7
+
8
+ __all__ = (
9
+ "input_video_clip",
10
+ "audio_spinner",
11
+ )
12
+
13
+ multimodal_dep = HTMLDependency(
14
+ "multimodal",
15
+ "0.0.1",
16
+ source={
17
+ "subdir": str(Path(__file__).parent / "dist"),
18
+ },
19
+ script={"src": "index.js"},
20
+ stylesheet={"href": "index.css"},
21
+ )
22
+
23
+
24
+ def input_video_clip(id: str, **kwargs):
25
+ id = module.resolve_id(id)
26
+
27
+ return ui.Tag(
28
+ "video-clipper",
29
+ multimodal_dep,
30
+ ui.Tag(
31
+ "av-settings-menu",
32
+ ui.div(
33
+ ui.tags.button(
34
+ icon_svg("gear").add_class("fw"),
35
+ class_="btn btn-sm btn-secondary dropdown-toggle px-3 py-2",
36
+ type="button",
37
+ **{"data-bs-toggle": "dropdown"},
38
+ ),
39
+ ui.tags.ul(
40
+ ui.tags.li(
41
+ ui.tags.h6("Camera", class_="dropdown-header"),
42
+ class_="camera-header",
43
+ ),
44
+ # Camera items will go here
45
+ ui.tags.li(ui.tags.hr(class_="dropdown-divider")),
46
+ ui.tags.li(
47
+ ui.tags.h6("Microphone", class_="dropdown-header"),
48
+ class_="mic-header",
49
+ ),
50
+ # Microphone items will go here
51
+ class_="dropdown-menu",
52
+ ),
53
+ class_="btn-group",
54
+ ),
55
+ slot="settings",
56
+ ),
57
+ ui.div(
58
+ ui.tags.button(
59
+ ui.TagList(
60
+ ui.tags.div(
61
+ style="display: inline-block; background-color: red; width: 1rem; height: 1rem; border-radius: 100%; position: relative; top: 0.175rem; margin-right: 0.3rem;"
62
+ ),
63
+ "Record",
64
+ ),
65
+ style="display: block;",
66
+ class_="record-button btn btn-secondary px-3 mx-auto",
67
+ ),
68
+ ui.tags.button(
69
+ ui.TagList(
70
+ ui.tags.div(
71
+ style="display: inline-block; background-color: currentColor; width: 1rem; height: 1rem; position: relative; top: 0.175rem; margin-right: 0.3rem;"
72
+ ),
73
+ "Stop",
74
+ ),
75
+ style="display: block;",
76
+ class_="stop-button btn btn-secondary px-3 mx-auto",
77
+ ),
78
+ slot="recording-controls",
79
+ class_="btn-group",
80
+ **{"aria-label": "Recording controls"},
81
+ ),
82
+ id=id,
83
+ **kwargs,
84
+ )
85
+
86
+
87
+ def audio_spinner(
88
+ *,
89
+ src: str,
90
+ spin_velocity: float = 1,
91
+ gap: float = math.pi / 5,
92
+ thickness: float = 2.5,
93
+ min_radius: float = 30,
94
+ radius_factor: float = 1.8,
95
+ steps: float = 3,
96
+ blades: float = 3,
97
+ **kwargs
98
+ ):
99
+ return ui.Tag(
100
+ "audio-spinner",
101
+ multimodal_dep,
102
+ src=src,
103
+ style="width: 125px; height: 125px;",
104
+ class_="mx-auto",
105
+ **{
106
+ "data-spin-velocity": spin_velocity,
107
+ "data-gap": gap,
108
+ "data-thickness": thickness,
109
+ "data-min-radius": min_radius,
110
+ "data-radius-factor": radius_factor,
111
+ "data-steps": steps,
112
+ "data-blades": blades,
113
+ },
114
+ )
videoinput/utils.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import contextlib
5
+ import os
6
+ import tempfile
7
+ import time
8
+
9
+ import colorama
10
+
11
+ __all__ = (
12
+ "timed",
13
+ "file_to_data_uri",
14
+ )
15
+
16
+
17
+ @contextlib.contextmanager
18
+ def timed(msg):
19
+ start = time.perf_counter()
20
+ print(colorama.Style.DIM + f"╔ {msg}" + colorama.Style.RESET_ALL)
21
+ yield
22
+ elapsed = time.perf_counter() - start
23
+ print(
24
+ colorama.Style.DIM + f"╚ Finished in {elapsed:.3f}s" + colorama.Style.RESET_ALL
25
+ )
26
+
27
+
28
+ def file_to_data_uri(file_path, mime_type):
29
+ with open(file_path, "rb") as file:
30
+ encoded_string = base64.b64encode(file.read()).decode("utf-8")
31
+ return f"data:{mime_type};base64,{encoded_string}"
32
+
33
+
34
+ class NamedTemporaryFile(contextlib.AbstractContextManager):
35
+ """
36
+ tempfile.NamedTemporaryFile with an additional `delete_on_close` parameter.
37
+
38
+ The `delete_on_close` parameter was only added in Python 3.12, but we badly
39
+ need it on Windows: because file access on Windows is exclusive, we can't
40
+ write to and then read from a file without closing it in between. But
41
+ without `delete_on_close`, the file is deleted on close.
42
+
43
+ This class is a thin shim around tempfile.NamedTemporaryFile that adds the
44
+ parameter for older Python versions.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ mode: str = "w+b",
50
+ buffering: int = -1,
51
+ encoding: str | None = None,
52
+ newline: str | None = None,
53
+ suffix: str = "",
54
+ prefix: str = "tmp",
55
+ dir: str | None = None,
56
+ delete: bool = True,
57
+ *,
58
+ errors: str | None = None,
59
+ delete_on_close: bool = True,
60
+ ):
61
+ self._needs_manual_delete = delete and not delete_on_close
62
+ self._file = tempfile.NamedTemporaryFile(
63
+ mode=mode,
64
+ buffering=buffering,
65
+ encoding=encoding,
66
+ newline=newline,
67
+ suffix=suffix,
68
+ prefix=prefix,
69
+ dir=dir,
70
+ delete=delete and delete_on_close,
71
+ errors=errors,
72
+ )
73
+
74
+ def __enter__(self) -> tempfile.NamedTemporaryFile:
75
+ return self._file.__enter__()
76
+
77
+ def __exit__(self, exc_type, exc_val, exc_tb):
78
+ self._file.__exit__(exc_type, exc_val, exc_tb)
79
+ if self._needs_manual_delete:
80
+ os.unlink(self._file.name)
videoinput/yarn.lock ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2
+ # yarn lockfile v1
3
+
4
+
5
+ "@esbuild/[email protected]":
6
+ version "0.21.3"
7
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.3.tgz#78d3e6dcd19c1cb91f3940143e86dad1094aee81"
8
+ integrity sha512-yTgnwQpFVYfvvo4SvRFB0SwrW8YjOxEoT7wfMT7Ol5v7v5LDNvSGo67aExmxOb87nQNeWPVvaGBNfQ7BXcrZ9w==
9
+
10
+ "@esbuild/[email protected]":
11
+ version "0.21.3"
12
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.3.tgz#5eea56c21d61734942e050840d881eb7bedc3993"
13
+ integrity sha512-c+ty9necz3zB1Y+d/N+mC6KVVkGUUOcm4ZmT5i/Fk5arOaY3i6CA3P5wo/7+XzV8cb4GrI/Zjp8NuOQ9Lfsosw==
14
+
15
+ "@esbuild/[email protected]":
16
+ version "0.21.3"
17
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.3.tgz#7fda92e3231043c071ea6aa76c92accea86439fd"
18
+ integrity sha512-bviJOLMgurLJtF1/mAoJLxDZDL6oU5/ztMHnJQRejbJrSc9FFu0QoUoFhvi6qSKJEw9y5oGyvr9fuDtzJ30rNQ==
19
+
20
+ "@esbuild/[email protected]":
21
+ version "0.21.3"
22
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.3.tgz#625d139bde81b81f54ff493b1381ca0f540200f3"
23
+ integrity sha512-JReHfYCRK3FVX4Ra+y5EBH1b9e16TV2OxrPAvzMsGeES0X2Ndm9ImQRI4Ket757vhc5XBOuGperw63upesclRw==
24
+
25
+ "@esbuild/[email protected]":
26
+ version "0.21.3"
27
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.3.tgz#fa25f38a43ff4c469589d1dc93448d534d7f313b"
28
+ integrity sha512-U3fuQ0xNiAkXOmQ6w5dKpEvXQRSpHOnbw7gEfHCRXPeTKW9sBzVck6C5Yneb8LfJm0l6le4NQfkNPnWMSlTFUQ==
29
+
30
+ "@esbuild/[email protected]":
31
+ version "0.21.3"
32
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.3.tgz#2e450b8214f179a56b4559b2f107060e2b792c7e"
33
+ integrity sha512-3m1CEB7F07s19wmaMNI2KANLcnaqryJxO1fXHUV5j1rWn+wMxdUYoPyO2TnAbfRZdi7ADRwJClmOwgT13qlP3Q==
34
+
35
+ "@esbuild/[email protected]":
36
+ version "0.21.3"
37
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.3.tgz#f6b29e07bce25c545f6f7bb031d3be6a6ea1dc50"
38
+ integrity sha512-fsNAAl5pU6wmKHq91cHWQT0Fz0vtyE1JauMzKotrwqIKAswwP5cpHUCxZNSTuA/JlqtScq20/5KZ+TxQdovU/g==
39
+
40
+ "@esbuild/[email protected]":
41
+ version "0.21.3"
42
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.3.tgz#1a5da2bf89f8d67102820d893d271a270ae55751"
43
+ integrity sha512-tci+UJ4zP5EGF4rp8XlZIdq1q1a/1h9XuronfxTMCNBslpCtmk97Q/5qqy1Mu4zIc0yswN/yP/BLX+NTUC1bXA==
44
+
45
+ "@esbuild/[email protected]":
46
+ version "0.21.3"
47
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.3.tgz#355f6624c1ac6f5f68841a327ac90b98c679626c"
48
+ integrity sha512-vvG6R5g5ieB4eCJBQevyDMb31LMHthLpXTc2IGkFnPWS/GzIFDnaYFp558O+XybTmYrVjxnryru7QRleJvmZ6Q==
49
+
50
+ "@esbuild/[email protected]":
51
+ version "0.21.3"
52
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.3.tgz#872a476ca18a962a98700024c447a79279db1d45"
53
+ integrity sha512-f6kz2QpSuyHHg01cDawj0vkyMwuIvN62UAguQfnNVzbge2uWLhA7TCXOn83DT0ZvyJmBI943MItgTovUob36SQ==
54
+
55
+ "@esbuild/[email protected]":
56
+ version "0.21.3"
57
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.3.tgz#da713eb80ff6c011ed01aa4deebb5fc758906046"
58
+ integrity sha512-HjCWhH7K96Na+66TacDLJmOI9R8iDWDDiqe17C7znGvvE4sW1ECt9ly0AJ3dJH62jHyVqW9xpxZEU1jKdt+29A==
59
+
60
+ "@esbuild/[email protected]":
61
+ version "0.21.3"
62
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.3.tgz#a7c5dc9e961009018d23ec53a43baa8c03c5a1d5"
63
+ integrity sha512-BGpimEccmHBZRcAhdlRIxMp7x9PyJxUtj7apL2IuoG9VxvU/l/v1z015nFs7Si7tXUwEsvjc1rOJdZCn4QTU+Q==
64
+
65
+ "@esbuild/[email protected]":
66
+ version "0.21.3"
67
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.3.tgz#b97543f3d8655365729f3702ed07f6e41be5e48e"
68
+ integrity sha512-5rMOWkp7FQGtAH3QJddP4w3s47iT20hwftqdm7b+loe95o8JU8ro3qZbhgMRy0VuFU0DizymF1pBKkn3YHWtsw==
69
+
70
+ "@esbuild/[email protected]":
71
+ version "0.21.3"
72
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.3.tgz#23b9064d5bc0bf28a115a2f9cf69f3b01cdfe01c"
73
+ integrity sha512-h0zj1ldel89V5sjPLo5H1SyMzp4VrgN1tPkN29TmjvO1/r0MuMRwJxL8QY05SmfsZRs6TF0c/IDH3u7XYYmbAg==
74
+
75
+ "@esbuild/[email protected]":
76
+ version "0.21.3"
77
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.3.tgz#4f2536327f6d444c0573bd35bbd3a67897dbd5da"
78
+ integrity sha512-dkAKcTsTJ+CRX6bnO17qDJbLoW37npd5gSNtSzjYQr0svghLJYGYB0NF1SNcU1vDcjXLYS5pO4qOW4YbFama4A==
79
+
80
+ "@esbuild/[email protected]":
81
+ version "0.21.3"
82
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.3.tgz#05e6f3a12a0dcd60672f25e8789a83cd3affa487"
83
+ integrity sha512-vnD1YUkovEdnZWEuMmy2X2JmzsHQqPpZElXx6dxENcIwTu+Cu5ERax6+Ke1QsE814Zf3c6rxCfwQdCTQ7tPuXA==
84
+
85
+ "@esbuild/[email protected]":
86
+ version "0.21.3"
87
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.3.tgz#994d347e7f530c33628e35e48ccde8f299adbcb6"
88
+ integrity sha512-IOXOIm9WaK7plL2gMhsWJd+l2bfrhfilv0uPTptoRoSb2p09RghhQQp9YY6ZJhk/kqmeRt6siRdMSLLwzuT0KQ==
89
+
90
+ "@esbuild/[email protected]":
91
+ version "0.21.3"
92
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.3.tgz#309d8c323632e9c70ee92cf5414fa65b5eb7e00e"
93
+ integrity sha512-uTgCwsvQ5+vCQnqM//EfDSuomo2LhdWhFPS8VL8xKf+PKTCrcT/2kPPoWMTs22aB63MLdGMJiE3f1PHvCDmUOw==
94
+
95
+ "@esbuild/[email protected]":
96
+ version "0.21.3"
97
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.3.tgz#28820f9431fe00f2b04aac57511754213ff060eb"
98
+ integrity sha512-vNAkR17Ub2MgEud2Wag/OE4HTSI6zlb291UYzHez/psiKarp0J8PKGDnAhMBcHFoOHMXHfExzmjMojJNbAStrQ==
99
+
100
+ "@esbuild/[email protected]":
101
+ version "0.21.3"
102
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.3.tgz#a1f7f98b85bd221fe0f545d01abc0e6123ae60dc"
103
+ integrity sha512-W8H9jlGiSBomkgmouaRoTXo49j4w4Kfbl6I1bIdO/vT0+0u4f20ko3ELzV3hPI6XV6JNBVX+8BC+ajHkvffIJA==
104
+
105
+ "@esbuild/[email protected]":
106
+ version "0.21.3"
107
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.3.tgz#c6c3c0b1a1dfc6327ef4db6aa4fb6efd9df531f7"
108
+ integrity sha512-EjEomwyLSCg8Ag3LDILIqYCZAq/y3diJ04PnqGRgq8/4O3VNlXyMd54j/saShaN4h5o5mivOjAzmU6C3X4v0xw==
109
+
110
+ "@esbuild/[email protected]":
111
+ version "0.21.3"
112
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.3.tgz#471b8d2cad1bd6479eee5acf04bba2c0e4d37e24"
113
+ integrity sha512-WGiE/GgbsEwR33++5rzjiYsKyHywE8QSZPF7Rfx9EBfK3Qn3xyR6IjyCr5Uk38Kg8fG4/2phN7sXp4NPWd3fcw==
114
+
115
+ "@esbuild/[email protected]":
116
+ version "0.21.3"
117
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.3.tgz#899c03576c4c28c83228f0e64dfa10edae99c9a2"
118
+ integrity sha512-xRxC0jaJWDLYvcUvjQmHCJSfMrgmUuvsoXgDeU/wTorQ1ngDdUBuFtgY3W1Pc5sprGAvZBtWdJX7RPg/iZZUqA==
119
+
120
+ "@types/[email protected]":
121
+ version "0.0.14"
122
+ resolved "https://registry.yarnpkg.com/@types/bootstrap-datepicker/-/bootstrap-datepicker-0.0.14.tgz#89bc5816dd0b802bf8d4923cf5461e2c98cbb450"
123
+ integrity sha512-ZBwEUuwYpehEHWtkaIDl55N3F6dPz6xEsoPW5esCZdtuhuZdNC/ebFV2n48kATcY5S2HD3V0O5/J8nuEgVk9fQ==
124
+ dependencies:
125
+ "@types/jquery" "*"
126
+
127
+ "@types/[email protected]":
128
+ version "3.4.0"
129
+ resolved "https://registry.yarnpkg.com/@types/bootstrap/-/bootstrap-3.4.0.tgz#7e74a2421b3984ce53ab459e979a99a78dd802c6"
130
+ integrity sha512-LS05hVAAsX86qbHg7W+ydwBlNHrVCoFw6wEP3/uW4eYmRXl08bWmPeN/+onM+8qZTFfDgUlG/OItJI8SW972oQ==
131
+ dependencies:
132
+ "@types/jquery" "*"
133
+
134
+ "@types/datatables.net@^1.10.19":
135
+ version "1.12.0"
136
+ resolved "https://registry.yarnpkg.com/@types/datatables.net/-/datatables.net-1.12.0.tgz#cc0b0eb875997515aa10a59969ecb5fb03423ea4"
137
+ integrity sha512-HjvusoHf/XKdNI/pDP/qVDclUVOKF/PI4R/AlVeUdz3Z00+A4Dyu9N5hIXGk3cK36sy1A072UffYZ+1wwW0zWg==
138
+ dependencies:
139
+ datatables.net "*"
140
+
141
+ "@types/[email protected]":
142
+ version "2.3.0"
143
+ resolved "https://registry.yarnpkg.com/@types/ion-rangeslider/-/ion-rangeslider-2.3.0.tgz#dd089921c21acebc266c37e95d191e721ea21750"
144
+ integrity sha512-u1k2Ekfg7E/ByCrtqtMk98g3QMXnMyIjCKU397YfjhWnTrms2BteoTanMqe0MJ1zyBBiP6+bwbc7P4cU+RiL+w==
145
+
146
+ "@types/jquery@*", "@types/jquery@^3.5.30":
147
+ version "3.5.30"
148
+ resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.30.tgz#888d584cbf844d3df56834b69925085038fd80f7"
149
+ integrity sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==
150
+ dependencies:
151
+ "@types/sizzle" "*"
152
+
153
+ "@types/jquery@patch:@types/[email protected]#./srcts/patch/types-jquery.patch":
154
+ version "3.5.5"
155
+ resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.5.tgz#2c63f47c9c8d96693d272f5453602afd8338c903"
156
+ integrity sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w==
157
+ dependencies:
158
+ "@types/sizzle" "*"
159
+
160
+ "@types/rstudio-shiny@https://github.com/rstudio/shiny#v1.7.0":
161
+ version "1.7.0"
162
+ resolved "https://github.com/rstudio/shiny#0fc861afb4c748b544ce1ac39be2e44563a7acf2"
163
+ dependencies:
164
+ "@types/bootstrap" "3.4.0"
165
+ "@types/bootstrap-datepicker" "0.0.14"
166
+ "@types/datatables.net" "^1.10.19"
167
+ "@types/ion-rangeslider" "2.3.0"
168
+ "@types/jquery" "patch:@types/[email protected]#./srcts/patch/types-jquery.patch"
169
+ "@types/selectize" "0.12.34"
170
+
171
+ "@types/[email protected]":
172
+ version "0.12.34"
173
+ resolved "https://registry.yarnpkg.com/@types/selectize/-/selectize-0.12.34.tgz#24a79e3176103019d2371ae9fc8487dcd1336cea"
174
+ integrity sha512-OAXHsgsHBMJBHnB6QrFlHVa0+gCE5lgjdpwJDlJLRhrcsM4I2VXrm2vsG00//Rpdm4XgnX17vrBpjHlWOk1ALw==
175
+
176
+ "@types/sizzle@*":
177
+ version "2.3.8"
178
+ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.8.tgz#518609aefb797da19bf222feb199e8f653ff7627"
179
+ integrity sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==
180
+
181
+ datatables.net@*:
182
+ version "2.0.7"
183
+ resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-2.0.7.tgz#8af61ac17334c37ae3bd77fc2828a3571e052041"
184
+ integrity sha512-cyW+HZwkMzb4bLrao/SS9/i64ZHiw5nYhXl+OwuOPgddG+R9O11iOEke8wYsdiyOQmjv2mE6xkEmRMZwHZc8zw==
185
+ dependencies:
186
+ jquery ">=1.7"
187
+
188
+ esbuild@^0.21.3:
189
+ version "0.21.3"
190
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.3.tgz#cbb10b100c768b0cfb35d61d9e70324553437c38"
191
+ integrity sha512-Kgq0/ZsAPzKrbOjCQcjoSmPoWhlcVnGAUo7jvaLHoxW1Drto0KGkR1xBNg2Cp43b9ImvxmPEJZ9xkfcnqPsfBw==
192
+ optionalDependencies:
193
+ "@esbuild/aix-ppc64" "0.21.3"
194
+ "@esbuild/android-arm" "0.21.3"
195
+ "@esbuild/android-arm64" "0.21.3"
196
+ "@esbuild/android-x64" "0.21.3"
197
+ "@esbuild/darwin-arm64" "0.21.3"
198
+ "@esbuild/darwin-x64" "0.21.3"
199
+ "@esbuild/freebsd-arm64" "0.21.3"
200
+ "@esbuild/freebsd-x64" "0.21.3"
201
+ "@esbuild/linux-arm" "0.21.3"
202
+ "@esbuild/linux-arm64" "0.21.3"
203
+ "@esbuild/linux-ia32" "0.21.3"
204
+ "@esbuild/linux-loong64" "0.21.3"
205
+ "@esbuild/linux-mips64el" "0.21.3"
206
+ "@esbuild/linux-ppc64" "0.21.3"
207
+ "@esbuild/linux-riscv64" "0.21.3"
208
+ "@esbuild/linux-s390x" "0.21.3"
209
+ "@esbuild/linux-x64" "0.21.3"
210
+ "@esbuild/netbsd-x64" "0.21.3"
211
+ "@esbuild/openbsd-x64" "0.21.3"
212
+ "@esbuild/sunos-x64" "0.21.3"
213
+ "@esbuild/win32-arm64" "0.21.3"
214
+ "@esbuild/win32-ia32" "0.21.3"
215
+ "@esbuild/win32-x64" "0.21.3"
216
+
217
+ jquery@>=1.7:
218
+ version "3.7.1"
219
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de"
220
+ integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==
221
+
222
+ typescript@^5.4.5:
223
+ version "5.4.5"
224
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611"
225
+ integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
www/Adelie.png DELETED
Binary file (79.1 kB)
 
www/Chinstrap.png DELETED
Binary file (82 kB)
 
www/Gentoo.png DELETED
Binary file (85.1 kB)
 
www/penguins.png DELETED
Binary file (279 kB)