视频复刻添加描述框
This commit is contained in:
@@ -700,6 +700,48 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
self.assertFalse(task.request_payload.get("shot_plan"))
|
self.assertFalse(task.request_payload.get("shot_plan"))
|
||||||
self.assertNotIn("@参考视频", task.request_payload["prompt"])
|
self.assertNotIn("@参考视频", task.request_payload["prompt"])
|
||||||
|
|
||||||
|
def test_subject_brief_reaches_product_facts(self):
|
||||||
|
"""第 3 步填的商品信息要进 facts,并把「仅外观」升级成「部分」。"""
|
||||||
|
task = self._submit(subject_brief=" 素颜霜,膏体按压泵,主打提亮 ")
|
||||||
|
payload = task.request_payload
|
||||||
|
self.assertEqual(payload["subject_brief"], "素颜霜,膏体按压泵,主打提亮")
|
||||||
|
facts = payload["product_facts"]
|
||||||
|
self.assertEqual(facts["用户补充信息"], "素颜霜,膏体按压泵,主打提亮")
|
||||||
|
self.assertEqual(facts["资料状态"], "部分")
|
||||||
|
|
||||||
|
def test_character_brief_drives_voice_gender(self):
|
||||||
|
"""角色信息里的性别要变成写死的声线要求。
|
||||||
|
|
||||||
|
这条守的是「女换男之后画面是男的、声音还是女的」:提示词以前引用了
|
||||||
|
一个根本不存在的「系统提供的目标声音设定」,模型只能猜,于是沿用原音轨。
|
||||||
|
"""
|
||||||
|
from apps.ai.video_replace import build_character_submit_prompt
|
||||||
|
|
||||||
|
male = build_character_submit_prompt("男性,30 岁左右,中低音域")
|
||||||
|
self.assertIn("成年男性声线", male)
|
||||||
|
self.assertNotIn("成年女性声线", male)
|
||||||
|
self.assertIn("男性,30 岁左右", male)
|
||||||
|
# 前缀必须原样保留:老任务靠 prompt 前缀认 replace_mode
|
||||||
|
self.assertTrue(male.startswith(CHARACTER_PROMPT))
|
||||||
|
|
||||||
|
self.assertIn("成年女性声线", build_character_submit_prompt("女性,25 岁,声音甜美"))
|
||||||
|
# 男女都提到(「把女的换成男的」)时不下断言,交给通用规则
|
||||||
|
self.assertNotIn("【目标声音设定】", build_character_submit_prompt("把女的换成男的"))
|
||||||
|
# 没填也要有性别跟随规则,不能再引用不存在的设定
|
||||||
|
bare = build_character_submit_prompt("")
|
||||||
|
self.assertIn("必须随目标角色改变", bare)
|
||||||
|
self.assertNotIn("系统提供的目标声音设定", bare)
|
||||||
|
|
||||||
|
def test_character_brief_lands_in_submitted_prompt(self):
|
||||||
|
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||||
|
model = Model.objects.create(team=self.team, name="阿哲", portrait_asset=portrait)
|
||||||
|
task = self._submit(
|
||||||
|
replace_mode="character", product_id="", model_id=str(model.id),
|
||||||
|
subject_brief="男性,稳重,中低音域",
|
||||||
|
)
|
||||||
|
self.assertEqual(task.request_payload["subject_brief"], "男性,稳重,中低音域")
|
||||||
|
self.assertIn("成年男性声线", task.request_payload["prompt"])
|
||||||
|
|
||||||
def test_character_library_and_temp_are_exclusive(self):
|
def test_character_library_and_temp_are_exclusive(self):
|
||||||
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||||
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
|
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ CHARACTER_PROMPT = (
|
|||||||
"【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、"
|
"【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、"
|
||||||
"语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;"
|
"语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;"
|
||||||
"不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。"
|
"不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。"
|
||||||
"根据@目标角色的身份及系统提供的目标声音设定,重新生成全部相关人声。"
|
"根据@目标角色的形象、下方【目标角色设定】,以及参考图中可见的性别与年龄特征,重新生成全部相关人声。"
|
||||||
|
"当@目标角色与参考视频中原人物的性别或年龄段不同时,重建人声的性别音色必须随目标角色改变:"
|
||||||
|
"男性角色必须用男声,女性角色必须用女声,绝不允许沿用原人物的性别音色。"
|
||||||
"从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、"
|
"从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、"
|
||||||
"新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。"
|
"新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。"
|
||||||
"画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。"
|
"画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。"
|
||||||
@@ -103,6 +105,51 @@ CHARACTER_PROMPT = (
|
|||||||
"如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。"
|
"如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。
|
||||||
|
MAX_SUBJECT_BRIEF = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_subject_brief(value) -> str:
|
||||||
|
return str(value or "").strip()[:MAX_SUBJECT_BRIEF]
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_gender_directive(brief: str) -> str:
|
||||||
|
"""从用户填的角色信息里读出性别 → 写死声线要求。
|
||||||
|
|
||||||
|
Seedance 拿不到明确性别时倾向于沿用原音轨声线,女换男会出现
|
||||||
|
「画面换成男的、声音还是女的」。用户明确写了性别就不再让模型猜。
|
||||||
|
男女都提到(例如「把女的换成男的」)时不下断言,交给上面的通用规则。
|
||||||
|
"""
|
||||||
|
has_male = "男" in brief
|
||||||
|
has_female = "女" in brief
|
||||||
|
if has_male and not has_female:
|
||||||
|
return (
|
||||||
|
"【目标声音设定】目标角色为男性:重建的全部人声必须是成年男性声线,中低音域,胸腔共鸣,"
|
||||||
|
"绝不允许出现女性音色、女性音高或偏女的中性声线。"
|
||||||
|
)
|
||||||
|
if has_female and not has_male:
|
||||||
|
return (
|
||||||
|
"【目标声音设定】目标角色为女性:重建的全部人声必须是成年女性声线,中高音域,"
|
||||||
|
"绝不允许出现男性音色或男性音高。"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_character_submit_prompt(subject_brief: str = "") -> str:
|
||||||
|
"""角色复刻的提交提示词 = 固定规则 + 用户填的角色设定 + 推导出的声线要求。
|
||||||
|
|
||||||
|
⚠️ CHARACTER_PROMPT 必须留在开头:老任务靠 prompt 前缀认 replace_mode。
|
||||||
|
"""
|
||||||
|
brief = _clean_subject_brief(subject_brief)
|
||||||
|
parts = [CHARACTER_PROMPT]
|
||||||
|
if brief:
|
||||||
|
parts.append(f"【目标角色设定】(用户填写,形象与声音的依据){brief}")
|
||||||
|
voice = _voice_gender_directive(brief)
|
||||||
|
if voice:
|
||||||
|
parts.append(voice)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
||||||
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
|
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
|
||||||
|
|
||||||
@@ -668,6 +715,7 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
|
|||||||
"replace_mode": replace_mode,
|
"replace_mode": replace_mode,
|
||||||
"subject_name": payload.get("subject_name") or "",
|
"subject_name": payload.get("subject_name") or "",
|
||||||
"subject_source": payload.get("subject_source") or "",
|
"subject_source": payload.get("subject_source") or "",
|
||||||
|
"subject_brief": payload.get("subject_brief") or "",
|
||||||
"product_id": payload.get("product_id") or "",
|
"product_id": payload.get("product_id") or "",
|
||||||
"model_id": payload.get("model_id") or "",
|
"model_id": payload.get("model_id") or "",
|
||||||
"review_stage": "reviewing" if reviewing else "",
|
"review_stage": "reviewing" if reviewing else "",
|
||||||
@@ -726,6 +774,9 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
if video_seconds > REPLACE_REF_DURATION_MAX:
|
if video_seconds > REPLACE_REF_DURATION_MAX:
|
||||||
raise ValueError("参考视频不能超过 30 秒,请剪短后重试")
|
raise ValueError("参考视频不能超过 30 秒,请剪短后重试")
|
||||||
|
|
||||||
|
# 第 3 步用户填的补充信息。商品:名称/品类/卖点;角色:性别/年龄/音色。选填。
|
||||||
|
subject_brief = _clean_subject_brief(params.get("subject_brief"))
|
||||||
|
|
||||||
if has_product:
|
if has_product:
|
||||||
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
|
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
|
||||||
product_facts = _product_semantic_facts_for_id(team, product_id)
|
product_facts = _product_semantic_facts_for_id(team, product_id)
|
||||||
@@ -744,6 +795,17 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。",
|
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 用户手填的商品信息优先级最高:临时上传时这往往是唯一的品类来源,
|
||||||
|
# 商品库选品时它补充库里没填的字段。填了就不再是「仅外观」。
|
||||||
|
if replace_mode == "product" and subject_brief:
|
||||||
|
product_facts = {
|
||||||
|
**product_facts,
|
||||||
|
"用户补充信息": subject_brief,
|
||||||
|
"用户补充信息的权威性": "这是用户手动填写的商品事实,可直接作为品类、用途和卖点的依据,优先级高于据图推断。",
|
||||||
|
}
|
||||||
|
if product_facts.get("资料状态") == "仅外观":
|
||||||
|
product_facts["资料状态"] = "部分"
|
||||||
|
|
||||||
# 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。
|
# 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。
|
||||||
duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds)
|
duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds)
|
||||||
billing_ratio = str(params.get("aspect_ratio") or "9:16")
|
billing_ratio = str(params.get("aspect_ratio") or "9:16")
|
||||||
@@ -756,6 +818,7 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
"product_id": str(product_id) if product_id else "",
|
"product_id": str(product_id) if product_id else "",
|
||||||
"model_id": str(model_id) if model_id else "",
|
"model_id": str(model_id) if model_id else "",
|
||||||
"product_facts": product_facts,
|
"product_facts": product_facts,
|
||||||
|
"subject_brief": subject_brief,
|
||||||
# adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。
|
# adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。
|
||||||
"billing_duration": max(4, int(round(video_seconds or 4))),
|
"billing_duration": max(4, int(round(video_seconds or 4))),
|
||||||
"billing_aspect_ratio": billing_ratio,
|
"billing_aspect_ratio": billing_ratio,
|
||||||
@@ -802,7 +865,10 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
user=user,
|
user=user,
|
||||||
params={
|
params={
|
||||||
**base_params,
|
**base_params,
|
||||||
"prompt": DIGEST_PENDING_PROMPT if replace_mode == "product" else CHARACTER_PROMPT,
|
"prompt": (
|
||||||
|
DIGEST_PENDING_PROMPT if replace_mode == "product"
|
||||||
|
else build_character_submit_prompt(subject_brief)
|
||||||
|
),
|
||||||
"references": image_refs,
|
"references": image_refs,
|
||||||
"extra_payload": extra,
|
"extra_payload": extra,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ export const api = {
|
|||||||
product_id?: string;
|
product_id?: string;
|
||||||
model_id?: string;
|
model_id?: string;
|
||||||
image_asset_ids?: string[];
|
image_asset_ids?: string[];
|
||||||
|
subject_brief?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
|
|||||||
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
||||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
|
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
|
||||||
const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
|
const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
|
||||||
|
// 第 3 步补充信息上限,与后端 MAX_SUBJECT_BRIEF 保持一致
|
||||||
|
const SUBJECT_BRIEF_MAX = 500;
|
||||||
|
|
||||||
type ProductSource = "library" | "temporary" | "";
|
type ProductSource = "library" | "temporary" | "";
|
||||||
type ReplaceMode = "product" | "character";
|
type ReplaceMode = "product" | "character";
|
||||||
@@ -74,6 +76,10 @@ const REPLACE_MODE_COPY = {
|
|||||||
drawerEmpty: "还没有商品,先去商品库创建一个",
|
drawerEmpty: "还没有商品,先去商品库创建一个",
|
||||||
historyKind: "商品",
|
historyKind: "商品",
|
||||||
pickToast: "已选择商品",
|
pickToast: "已选择商品",
|
||||||
|
briefStep: "3. 商品信息",
|
||||||
|
briefOptional: "选填 · 填了更准",
|
||||||
|
briefPlaceholder: "例:素颜霜,膏体按压泵,主打提亮和保湿,适合日常通勤",
|
||||||
|
briefHint: "填写商品名称、品类和卖点后,口播和使用动作会按真实用途来写;不填则只按参考图的外观拍。",
|
||||||
},
|
},
|
||||||
character: {
|
character: {
|
||||||
modeLabel: "角色复刻",
|
modeLabel: "角色复刻",
|
||||||
@@ -101,6 +107,10 @@ const REPLACE_MODE_COPY = {
|
|||||||
drawerEmpty: "还没有人物,先去模特库添加",
|
drawerEmpty: "还没有人物,先去模特库添加",
|
||||||
historyKind: "角色",
|
historyKind: "角色",
|
||||||
pickToast: "已选择角色",
|
pickToast: "已选择角色",
|
||||||
|
briefStep: "3. 角色信息",
|
||||||
|
briefOptional: "选填 · 建议写明性别",
|
||||||
|
briefPlaceholder: "例:男性,30 岁左右,中低音域,语速平稳,声音干净有亲和力",
|
||||||
|
briefHint: "配音会按这里的设定重新生成声线,建议写明男 / 女,避免沿用原视频人物的音色。",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -284,6 +294,7 @@ interface ModeFormState {
|
|||||||
tempFiles: File[];
|
tempFiles: File[];
|
||||||
tempAssetRefs: FreeVideoRef[];
|
tempAssetRefs: FreeVideoRef[];
|
||||||
filledSubjectName: string;
|
filledSubjectName: string;
|
||||||
|
subjectBrief: string;
|
||||||
job: FreeVideoTask | null;
|
job: FreeVideoTask | null;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
}
|
}
|
||||||
@@ -300,6 +311,7 @@ const createInitialModeState = (): ModeFormState => ({
|
|||||||
tempFiles: [],
|
tempFiles: [],
|
||||||
tempAssetRefs: [],
|
tempAssetRefs: [],
|
||||||
filledSubjectName: "",
|
filledSubjectName: "",
|
||||||
|
subjectBrief: "",
|
||||||
job: null,
|
job: null,
|
||||||
jobId: "",
|
jobId: "",
|
||||||
});
|
});
|
||||||
@@ -357,6 +369,7 @@ export function VideoReplacePage({
|
|||||||
tempFiles,
|
tempFiles,
|
||||||
tempAssetRefs,
|
tempAssetRefs,
|
||||||
filledSubjectName,
|
filledSubjectName,
|
||||||
|
subjectBrief,
|
||||||
job,
|
job,
|
||||||
jobId,
|
jobId,
|
||||||
} = currentForm;
|
} = currentForm;
|
||||||
@@ -824,6 +837,7 @@ export function VideoReplacePage({
|
|||||||
product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined,
|
product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined,
|
||||||
model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined,
|
model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined,
|
||||||
image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined,
|
image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined,
|
||||||
|
subject_brief: current.subjectBrief.trim() || undefined,
|
||||||
model: preferredModel.name,
|
model: preferredModel.name,
|
||||||
aspect_ratio: aspectRatio,
|
aspect_ratio: aspectRatio,
|
||||||
resolution: "720p",
|
resolution: "720p",
|
||||||
@@ -902,6 +916,7 @@ export function VideoReplacePage({
|
|||||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||||
},
|
},
|
||||||
filledSubjectName: subject,
|
filledSubjectName: subject,
|
||||||
|
subjectBrief: task.subject_brief || "",
|
||||||
selectedModel: model || null,
|
selectedModel: model || null,
|
||||||
selectedProduct: null,
|
selectedProduct: null,
|
||||||
source: model ? "library" : (images.length ? "temporary" : ""),
|
source: model ? "library" : (images.length ? "temporary" : ""),
|
||||||
@@ -926,6 +941,7 @@ export function VideoReplacePage({
|
|||||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||||
},
|
},
|
||||||
filledSubjectName: subject,
|
filledSubjectName: subject,
|
||||||
|
subjectBrief: task.subject_brief || "",
|
||||||
selectedProduct: product || null,
|
selectedProduct: product || null,
|
||||||
selectedModel: null,
|
selectedModel: null,
|
||||||
source: product ? "library" : (images.length ? "temporary" : ""),
|
source: product ? "library" : (images.length ? "temporary" : ""),
|
||||||
@@ -1224,6 +1240,23 @@ export function VideoReplacePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="video-flow-step">
|
||||||
|
<div className="video-flow-step-head">
|
||||||
|
<strong>{copy.briefStep}</strong>
|
||||||
|
<span>{copy.briefOptional}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="textarea replace-brief-input"
|
||||||
|
value={subjectBrief}
|
||||||
|
maxLength={SUBJECT_BRIEF_MAX}
|
||||||
|
placeholder={copy.briefPlaceholder}
|
||||||
|
disabled={generating}
|
||||||
|
aria-label={copy.briefStep}
|
||||||
|
onChange={(event) => updateCurrentForm({ subjectBrief: event.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="field-hint replace-brief-hint">{copy.briefHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="video-flow-actions replace-generate-action">
|
<div className="video-flow-actions replace-generate-action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ export type FreeVideoTask = {
|
|||||||
replace_mode?: "product" | "character" | "";
|
replace_mode?: "product" | "character" | "";
|
||||||
subject_name?: string;
|
subject_name?: string;
|
||||||
subject_source?: "library" | "temporary" | "";
|
subject_source?: "library" | "temporary" | "";
|
||||||
|
subject_brief?: string;
|
||||||
product_id?: string;
|
product_id?: string;
|
||||||
model_id?: string;
|
model_id?: string;
|
||||||
review_stage?: "reviewing" | "";
|
review_stage?: "reviewing" | "";
|
||||||
|
|||||||
@@ -203,6 +203,11 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 第 3 步补充信息。输入框本体复用共享 .textarea,这里只给提示文案留出间距。 */
|
||||||
|
.vrep-page .replace-brief-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.vrep-page .video-upload-field {
|
.vrep-page .video-upload-field {
|
||||||
min-height: 148px;
|
min-height: 148px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user