Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
A
ai-box
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
青山
ai-box
Commits
a8eeb011
Commit
a8eeb011
authored
Nov 12, 2024
by
fisherdaddy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feature: 增加字幕拼接工具
parent
564294bc
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
559 additions
and
1 deletion
+559
-1
subtitle2image.png
public/assets/icon/subtitle2image.png
+0
-0
App.jsx
src/App.jsx
+2
-0
SubtitleGenerator.jsx
src/components/SubtitleGenerator.jsx
+465
-0
tools.json
src/locales/en/tools.json
+22
-0
tools.json
src/locales/ja/tools.json
+22
-0
tools.json
src/locales/ko/tools.json
+22
-0
tools.json
src/locales/zh/tools.json
+23
-1
Home.jsx
src/pages/Home.jsx
+1
-0
ImageTools.jsx
src/pages/ImageTools.jsx
+2
-0
No files found.
public/assets/icon/subtitle2image.png
0 → 100644
View file @
a8eeb011
219 KB
src/App.jsx
View file @
a8eeb011
...
...
@@ -22,6 +22,7 @@ const ImageBase64Converter = lazy(() => import('./components/ImageBase64Convert
const
QuoteCard
=
lazy
(()
=>
import
(
'./components/QuoteCard'
));
const
LatexToImage
=
lazy
(()
=>
import
(
'./components/LatexToImage'
));
const
TextDiff
=
lazy
(()
=>
import
(
'./components/TextDiff'
));
const
SubtitleGenerator
=
lazy
(()
=>
import
(
'./components/SubtitleGenerator'
));
function
App
()
{
return
(
...
...
@@ -50,6 +51,7 @@ function App() {
<
Route
path=
"/quote-card"
element=
{
<
QuoteCard
/>
}
/>
<
Route
path=
"/latex-to-image"
element=
{
<
LatexToImage
/>
}
/>
<
Route
path=
"/text-diff"
element=
{
<
TextDiff
/>
}
/>
<
Route
path=
"/subtitle-to-image"
element=
{
<
SubtitleGenerator
/>
}
/>
<
Route
path=
"*"
element=
{
<
NotFound
/>
}
/>
</
Routes
>
...
...
src/components/SubtitleGenerator.jsx
0 → 100644
View file @
a8eeb011
import
React
,
{
useState
,
useRef
,
useEffect
}
from
'react'
;
import
styled
from
'styled-components'
;
import
{
useTranslation
}
from
'../js/i18n'
;
const
SubtitleMaker
=
()
=>
{
const
{
t
}
=
useTranslation
();
const
[
imageSrc
,
setImageSrc
]
=
useState
(
null
);
const
[
subtitles
,
setSubtitles
]
=
useState
([{
text
:
''
}]);
const
canvasRef
=
useRef
(
null
);
const
[
finalImage
,
setFinalImage
]
=
useState
(
null
);
const
[
globalSettings
,
setGlobalSettings
]
=
useState
({
fontSize
:
48
,
lineHeight
:
100
,
strokeWidth
:
2
,
textColor
:
'#FFE135'
,
strokeColor
:
'#000000'
});
// 预设的字幕颜色选项 - 只保留最常用的几个
const
presetColors
=
[
{
name
:
t
(
'tools.subtitleGenerator.presetColors.classicYellow'
),
value
:
'#FFE135'
},
{
name
:
t
(
'tools.subtitleGenerator.presetColors.pureWhite'
),
value
:
'#FFFFFF'
},
{
name
:
t
(
'tools.subtitleGenerator.presetColors.vividOrange'
),
value
:
'#FFA500'
},
{
name
:
t
(
'tools.subtitleGenerator.presetColors.neonGreen'
),
value
:
'#ADFF2F'
},
{
name
:
t
(
'tools.subtitleGenerator.presetColors.lightBlue'
),
value
:
'#00FFFF'
},
{
name
:
t
(
'tools.subtitleGenerator.presetColors.brightPink'
),
value
:
'#FF69B4'
},
];
// 处理图片上传
const
handleImageUpload
=
(
e
)
=>
{
const
file
=
e
.
target
.
files
[
0
];
if
(
file
)
{
const
reader
=
new
FileReader
();
reader
.
onload
=
()
=>
{
setImageSrc
(
reader
.
result
);
}
reader
.
readAsDataURL
(
file
);
}
}
// 更新字幕内容
const
updateSubtitle
=
(
index
,
text
)
=>
{
const
newSubtitles
=
[...
subtitles
];
newSubtitles
[
index
]
=
{
text
};
setSubtitles
(
newSubtitles
);
};
// 删除字幕行
const
removeSubtitleLine
=
(
index
)
=>
{
setSubtitles
(
subtitles
.
filter
((
_
,
i
)
=>
i
!==
index
));
};
// 增加字幕行
const
addSubtitleLine
=
()
=>
{
setSubtitles
([...
subtitles
,
{
text
:
''
}]);
};
// 绘制字幕到canvas
useEffect
(()
=>
{
if
(
imageSrc
)
{
const
canvas
=
canvasRef
.
current
;
const
ctx
=
canvas
.
getContext
(
'2d'
);
const
image
=
new
Image
();
image
.
src
=
imageSrc
;
image
.
onload
=
()
=>
{
// 计算总高度
const
totalHeight
=
image
.
height
+
subtitles
.
slice
(
1
).
length
*
(
globalSettings
.
lineHeight
+
1
);
canvas
.
width
=
image
.
width
;
canvas
.
height
=
totalHeight
;
// 绘制图片
ctx
.
clearRect
(
0
,
0
,
canvas
.
width
,
canvas
.
height
);
ctx
.
drawImage
(
image
,
0
,
0
);
// 获取第一行字幕区域的背景图像数据
const
subtitleBackgroundData
=
ctx
.
getImageData
(
0
,
image
.
height
-
globalSettings
.
lineHeight
,
canvas
.
width
,
globalSettings
.
lineHeight
);
// 设置字体和对齐方式
ctx
.
textAlign
=
'center'
;
ctx
.
font
=
`
${
globalSettings
.
fontSize
}
px Arial`
;
ctx
.
lineWidth
=
globalSettings
.
strokeWidth
;
// 绘制第一行字幕
let
yPosition
=
image
.
height
-
globalSettings
.
lineHeight
;
ctx
.
strokeStyle
=
globalSettings
.
strokeColor
;
ctx
.
fillStyle
=
globalSettings
.
textColor
;
const
firstTextY
=
yPosition
+
globalSettings
.
lineHeight
/
2
+
globalSettings
.
fontSize
/
3
;
ctx
.
strokeText
(
subtitles
[
0
].
text
,
canvas
.
width
/
2
,
firstTextY
);
ctx
.
fillText
(
subtitles
[
0
].
text
,
canvas
.
width
/
2
,
firstTextY
);
// 设置 yPosition 为图片底部,准备绘制后续字幕
yPosition
=
image
.
height
;
// 绘制后续字幕行
subtitles
.
slice
(
1
).
forEach
((
subtitle
)
=>
{
// 绘制分隔线
ctx
.
fillStyle
=
'#e5e7eb'
;
ctx
.
fillRect
(
0
,
yPosition
,
canvas
.
width
,
1
);
yPosition
+=
0
;
// 绘制背景(使用第一行字幕的背景)
ctx
.
putImageData
(
subtitleBackgroundData
,
0
,
yPosition
);
// 绘制字幕文字
ctx
.
strokeStyle
=
globalSettings
.
strokeColor
;
ctx
.
fillStyle
=
globalSettings
.
textColor
;
const
textY
=
yPosition
+
globalSettings
.
lineHeight
/
2
+
globalSettings
.
fontSize
/
3
;
ctx
.
strokeText
(
subtitle
.
text
,
canvas
.
width
/
2
,
textY
);
ctx
.
fillText
(
subtitle
.
text
,
canvas
.
width
/
2
,
textY
);
yPosition
+=
globalSettings
.
lineHeight
;
});
setFinalImage
(
canvas
.
toDataURL
(
'image/png'
));
};
}
},
[
imageSrc
,
subtitles
,
globalSettings
]);
// 下载最终的图片
const
downloadImage
=
()
=>
{
if
(
finalImage
)
{
const
link
=
document
.
createElement
(
'a'
);
link
.
href
=
finalImage
;
link
.
download
=
'subtitle-image.png'
;
link
.
click
();
}
}
return
(
<
Container
>
<
SettingsPanel
>
<
h3
>
{
t
(
'tools.subtitleGenerator.uploadImage'
)
}
</
h3
>
<
FileInput
type=
"file"
accept=
"image/*"
onChange=
{
handleImageUpload
}
/>
{
imageSrc
&&
(
<>
<
h3
>
{
t
(
'tools.subtitleGenerator.globalSettings'
)
}
</
h3
>
<
SettingGroup
>
<
label
>
{
t
(
'tools.subtitleGenerator.fontColor'
)
}
</
label
>
<
ColorPickerContainer
>
<
ColorInput
type=
"color"
value=
{
globalSettings
.
textColor
}
onChange=
{
(
e
)
=>
setGlobalSettings
({
...
globalSettings
,
textColor
:
e
.
target
.
value
})
}
/>
<
ColorPresets
>
{
presetColors
.
map
((
color
)
=>
(
<
ColorPresetButton
key=
{
color
.
value
}
$color=
{
color
.
value
}
onClick=
{
()
=>
setGlobalSettings
({
...
globalSettings
,
textColor
:
color
.
value
})
}
title=
{
color
.
name
}
/>
))
}
</
ColorPresets
>
</
ColorPickerContainer
>
</
SettingGroup
>
<
SettingGroup
>
<
label
>
{
t
(
'tools.subtitleGenerator.fontSize'
)
}
:
{
globalSettings
.
fontSize
}
px
</
label
>
<
RangeInput
type=
"range"
value=
{
globalSettings
.
fontSize
}
onChange=
{
(
e
)
=>
setGlobalSettings
({
...
globalSettings
,
fontSize
:
parseInt
(
e
.
target
.
value
)
})
}
min=
"48"
max=
"120"
/>
</
SettingGroup
>
<
SettingGroup
>
<
label
>
{
t
(
'tools.subtitleGenerator.subtitleHeight'
)
}
:
{
globalSettings
.
lineHeight
}
px
</
label
>
<
RangeInput
type=
"range"
value=
{
globalSettings
.
lineHeight
}
onChange=
{
(
e
)
=>
setGlobalSettings
({
...
globalSettings
,
lineHeight
:
parseInt
(
e
.
target
.
value
)
})
}
min=
"60"
max=
"160"
/>
</
SettingGroup
>
<
h3
>
{
t
(
'tools.subtitleGenerator.subtitleSettings'
)
}
</
h3
>
{
subtitles
.
map
((
subtitle
,
index
)
=>
(
<
SubtitleInput
key=
{
index
}
>
<
input
type=
"text"
value=
{
subtitle
.
text
}
onChange=
{
(
e
)
=>
updateSubtitle
(
index
,
e
.
target
.
value
)
}
placeholder=
{
`字幕 ${index + 1}`
}
/>
{
index
>
0
&&
(
<
DeleteButton
onClick=
{
()
=>
removeSubtitleLine
(
index
)
}
>
删除
</
DeleteButton
>
)
}
</
SubtitleInput
>
))
}
<
Button
onClick=
{
addSubtitleLine
}
>
{
t
(
'tools.subtitleGenerator.addSubtitleLine'
)
}
</
Button
>
</>
)
}
</
SettingsPanel
>
<
PreviewPanel
>
<
h3
>
{
t
(
'tools.subtitleGenerator.preview'
)
}
</
h3
>
{
finalImage
&&
(
<>
<
PreviewImage
src=
{
finalImage
}
alt=
"Preview"
/>
<
Button
onClick=
{
downloadImage
}
>
{
t
(
'tools.subtitleGenerator.downloadImage'
)
}
</
Button
>
</>
)
}
<
canvas
ref=
{
canvasRef
}
style=
{
{
display
:
'none'
}
}
/>
</
PreviewPanel
>
</
Container
>
);
};
// Styled Components
const
Container
=
styled
.
div
`
display: flex;
gap: 2rem;
padding: 2rem;
background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
min-height: 100vh;
@media (max-width: 768px) {
flex-direction: column;
}
`
;
const
SettingsPanel
=
styled
.
div
`
flex: 1;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
`
;
const
PreviewPanel
=
styled
.
div
`
flex: 1;
text-align: center;
`
;
const
Button
=
styled
.
button
`
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
`
;
const
DeleteButton
=
styled
(
Button
)
`
background: #ef4444;
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
`
;
const
SubtitleInput
=
styled
.
div
`
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
align-items: center;
input[type="text"] {
flex: 1;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
}
`
;
const
NumberInput
=
styled
.
input
`
width: 80px;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
`
;
const
Select
=
styled
.
select
`
width: 100%;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1rem;
`
;
const
PreviewImage
=
styled
.
img
`
max-width: 100%;
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
`
;
const
FileInput
=
styled
.
input
`
width: 100%;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1rem;
cursor: pointer;
&::-webkit-file-upload-button {
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-right: 1rem;
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
}
`
;
const
SettingGroup
=
styled
.
div
`
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
`
;
const
RangeInput
=
styled
.
input
`
width: 100%;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
outline: none;
-webkit-appearance: none;
margin: 10px 0;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #6366F1;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
background: #4F46E5;
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
background: #6366F1;
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
background: #4F46E5;
}
}
&::-ms-thumb {
width: 18px;
height: 18px;
background: #6366F1;
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
background: #4F46E5;
}
}
`
;
const
ColorPickerContainer
=
styled
.
div
`
display: flex;
gap: 1rem;
align-items: center;
`
;
const
ColorInput
=
styled
.
input
`
-webkit-appearance: none;
width: 48px;
height: 48px;
border: none;
border-radius: 10px;
cursor: pointer;
padding: 0;
background: none;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: 2px solid #e5e7eb;
border-radius: 8px;
}
`
;
const
ColorPresets
=
styled
.
div
`
display: flex;
gap: 0.5rem;
align-items: center;
`
;
const
ColorPresetButton
=
styled
.
button
`
width: 32px;
height: 32px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background-color:
${
props
=>
props
.
$color
}
;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
`
;
export
default
SubtitleMaker
;
src/locales/en/tools.json
View file @
a8eeb011
...
...
@@ -112,5 +112,27 @@
"newText"
:
"New Text"
,
"originalPlaceholder"
:
"Enter original text here..."
,
"newPlaceholder"
:
"Enter new text here..."
},
"subtitleGenerator"
:
{
"title"
:
"Subtitle Generator"
,
"description"
:
"Quickly generate multi-line subtitle images with customizable styles"
,
"uploadImage"
:
"Upload Background Image"
,
"removeImage"
:
"Remove Image"
,
"globalSettings"
:
"Global Settings"
,
"fontColor"
:
"Font Color"
,
"fontSize"
:
"Font Size"
,
"subtitleHeight"
:
"Subtitle Height"
,
"subtitleSettings"
:
"Subtitle Settings"
,
"addSubtitleLine"
:
"Add Subtitle Line"
,
"preview"
:
"Preview"
,
"downloadImage"
:
"Download Image"
,
"presetColors"
:
{
"classicYellow"
:
"Classic Yellow"
,
"pureWhite"
:
"Pure White"
,
"vividOrange"
:
"Vivid Orange"
,
"neonGreen"
:
"Neon Green"
,
"lightBlue"
:
"Light Blue"
,
"brightPink"
:
"Bright Pink"
}
}
}
\ No newline at end of file
src/locales/ja/tools.json
View file @
a8eeb011
...
...
@@ -112,5 +112,27 @@
"newText"
:
"新しいテキスト"
,
"originalPlaceholder"
:
"元のテキストを入力..."
,
"newPlaceholder"
:
"新しいテキストを入力..."
},
"subtitleGenerator"
:
{
"title"
:
"字幕生成ツール"
,
"description"
:
"複数行の字幕画像を素早く生成、スタイルのカスタマイズも可能"
,
"uploadImage"
:
"背景画像をアップロード"
,
"removeImage"
:
"画像を削除"
,
"globalSettings"
:
"グローバル設定"
,
"fontColor"
:
"フォントカラー"
,
"fontSize"
:
"フォントサイズ"
,
"subtitleHeight"
:
"字幕の高さ"
,
"subtitleSettings"
:
"字幕設定"
,
"addSubtitleLine"
:
"字幕行を追加"
,
"preview"
:
"プレビュー"
,
"downloadImage"
:
"画像をダウンロード"
,
"presetColors"
:
{
"classicYellow"
:
"クラシックイエロー"
,
"pureWhite"
:
"ピュアホワイト"
,
"vividOrange"
:
"ビビッドオレンジ"
,
"neonGreen"
:
"ネオングリーン"
,
"lightBlue"
:
"ライトブルー"
,
"brightPink"
:
"ブライトピンク"
}
}
}
\ No newline at end of file
src/locales/ko/tools.json
View file @
a8eeb011
...
...
@@ -113,5 +113,27 @@
"newText"
:
"새 텍스트"
,
"originalPlaceholder"
:
"원본 텍스트를 입력하세요..."
,
"newPlaceholder"
:
"새 텍스트를 입력하세요..."
},
"subtitleGenerator"
:
{
"title"
:
"자막 생성기"
,
"description"
:
"여러 줄의 자막 이미지를 빠르게 생성, 스타일 커스터마이징 지원"
,
"uploadImage"
:
"배경 이미지 업로드"
,
"removeImage"
:
"이미지 제거"
,
"globalSettings"
:
"전역 설정"
,
"fontColor"
:
"글자 색상"
,
"fontSize"
:
"글자 크기"
,
"subtitleHeight"
:
"자막 높이"
,
"subtitleSettings"
:
"자막 설정"
,
"addSubtitleLine"
:
"자막 줄 추가"
,
"preview"
:
"미리보기"
,
"downloadImage"
:
"이미지 다운로드"
,
"presetColors"
:
{
"classicYellow"
:
"클래식 옐로우"
,
"pureWhite"
:
"순수 화이트"
,
"vividOrange"
:
"비비드 오렌지"
,
"neonGreen"
:
"네온 그린"
,
"lightBlue"
:
"라이트 블루"
,
"brightPink"
:
"브라이트 핑크"
}
}
}
\ No newline at end of file
src/locales/zh/tools.json
View file @
a8eeb011
...
...
@@ -111,5 +111,27 @@
"newText"
:
"新文本"
,
"originalPlaceholder"
:
"在此输入原始文本..."
,
"newPlaceholder"
:
"在此输入新文本..."
},
"subtitleGenerator"
:
{
"title"
:
"字幕拼接工具"
,
"description"
:
"快速生成多行字幕图片,支持自定义样式"
,
"uploadImage"
:
"上传背景图片"
,
"removeImage"
:
"移除图片"
,
"globalSettings"
:
"全局设置"
,
"fontColor"
:
"字体颜色"
,
"fontSize"
:
"字体大小"
,
"subtitleHeight"
:
"字幕高度"
,
"subtitleSettings"
:
"字幕设置"
,
"addSubtitleLine"
:
"添加字幕行"
,
"preview"
:
"预览"
,
"downloadImage"
:
"下载图片"
,
"presetColors"
:
{
"classicYellow"
:
"经典黄"
,
"pureWhite"
:
"纯白"
,
"vividOrange"
:
"活力橙"
,
"neonGreen"
:
"荧光绿"
,
"lightBlue"
:
"浅蓝"
,
"brightPink"
:
"亮粉"
}
}
}
src/pages/Home.jsx
View file @
a8eeb011
...
...
@@ -7,6 +7,7 @@ const tools = [
{
id
:
'handwrite'
,
icon
:
'/assets/icon/handwrite.png'
,
path
:
'/handwriting'
},
{
id
:
'quoteCard'
,
icon
:
'/assets/icon/quotecard.png'
,
path
:
'/quote-card'
},
{
id
:
'markdown2image'
,
icon
:
'/assets/icon/markdown2image.png'
,
path
:
'/markdown-to-image'
},
{
id
:
'subtitleGenerator'
,
icon
:
'/assets/icon/subtitle2image.png'
,
path
:
'/subtitle-to-image'
},
{
id
:
'latex2image'
,
icon
:
'/assets/icon/latex2image.png'
,
path
:
'/latex-to-image'
},
{
id
:
'jsonFormatter'
,
icon
:
'/assets/icon/json-format.png'
,
path
:
'/json-formatter'
},
...
...
src/pages/ImageTools.jsx
View file @
a8eeb011
...
...
@@ -8,6 +8,8 @@ const tools = [
{
id
:
'quoteCard'
,
icon
:
'/assets/icon/quotecard.png'
,
path
:
'/quote-card'
},
{
id
:
'markdown2image'
,
icon
:
'/assets/icon/markdown2image.png'
,
path
:
'/markdown-to-image'
},
{
id
:
'latex2image'
,
icon
:
'/assets/icon/latex2image.png'
,
path
:
'/latex-to-image'
},
{
id
:
'subtitleGenerator'
,
icon
:
'/assets/icon/subtitle2image.png'
,
path
:
'/subtitle-to-image'
},
];
const
ImageTools
=
()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment