Browse Source

fix:文字单独加情绪

dev
leiyuee 4 days ago
parent
commit
6506b9fa30
  1. 2
      index.html
  2. 57
      src/api/api.ts
  3. BIN
      src/assets/images/explore/hero/heroBG1.png
  4. BIN
      src/assets/images/explore/hero/heroBG3.png
  5. 4
      src/components/AssetGalleryTile/index.scss
  6. 34
      src/dialog/index.tsx
  7. 418
      src/dialog/pay-subscribe/index.tsx
  8. 3
      src/locales/en-US.json
  9. 5
      src/locales/zh-CN.json
  10. 5
      src/locales/zh-TW.json
  11. 2
      src/pages/Explore/ExploreTemplateFeed/ExploreTemplateFeed.scss
  12. 199
      src/pages/Voice/components/speech/VoiceSpeechPanel.tsx
  13. 82
      src/pages/Voice/index.scss

2
index.html

@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Popi.art | AI动画制作平台 · 角色生成 · AI分镜(故事板)</title>
<title>POPi.art | AI动画制作平台 · 角色生成 · AI分镜(故事板)</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 禁止缓存入口页,部署后用户普通刷新即可拿到最新版本 -->

57
src/api/api.ts

@ -72,6 +72,7 @@ type PaymentStatusResponse = {
message?: string;
data?: PaymentStatusData;
};
const paymentRequestConfig: AxiosRequestConfig = { skipGlobalErrorHandler: true };
export type AuthUser = Record<string, unknown>;
export type AuthData = ApiParams & {
token?: string;
@ -610,54 +611,70 @@ export const getPlanList = (type: EntityId): Promise<ListApiResponse<ProductPlan
// 订阅支付宝下单
export const createSubscriptionOrder = (params: ApiPayload): Promise<PaymentOrderResponse> => {
return request.post("/trade/subscription/payByGatewayAliPay", {
origin: "skillhub",
...params,
});
return request.post(
"/trade/subscription/payByGatewayAliPay",
{
origin: "skillhub",
...params,
},
paymentRequestConfig,
);
};
// 订阅微信下单
export const createSubscriptionOrderByWxPay = (params: ApiPayload): Promise<PaymentOrderResponse> => {
return request.post("/trade/subscription/payByGatewayWxPay", {
origin: "skillhub",
...params,
});
return request.post(
"/trade/subscription/payByGatewayWxPay",
{
origin: "skillhub",
...params,
},
paymentRequestConfig,
);
};
// 订阅支付宝支付结果查询
export const checkGatewayPayment = (params: ApiPayload): Promise<PaymentStatusResponse> => {
return request.post("/trade/subscription/checkGatewayPayment", params);
return request.post("/trade/subscription/checkGatewayPayment", params, paymentRequestConfig);
};
// 订阅微信支付结果查询
export const checkGatewayWxPayment = (params: ApiPayload): Promise<PaymentStatusResponse> => {
return request.post("/trade/subscription/checkGatewayWxPayment", params);
return request.post("/trade/subscription/checkGatewayWxPayment", params, paymentRequestConfig);
};
// 积分包支付宝下单
export const createPointsOrder = (params: ApiPayload): Promise<PaymentOrderResponse> => {
return request.post("/users/pointPackage/payByGatewayAliPay", {
origin: "skillhub",
...params,
});
return request.post(
"/users/pointPackage/payByGatewayAliPay",
{
origin: "skillhub",
...params,
},
paymentRequestConfig,
);
};
// 积分包微信下单
export const createPointsOrderByWxPay = (params: ApiPayload): Promise<PaymentOrderResponse> => {
return request.post("/users/pointPackage/payByGatewayWxPay", {
origin: "skillhub",
...params,
});
return request.post(
"/users/pointPackage/payByGatewayWxPay",
{
origin: "skillhub",
...params,
},
paymentRequestConfig,
);
};
// 积分包支付宝支付结果查询
export const checkPointsPayment = (params: ApiPayload): Promise<PaymentStatusResponse> => {
return request.post("/users/pointPackage/checkGatewayPayment", params);
return request.post("/users/pointPackage/checkGatewayPayment", params, paymentRequestConfig);
};
// 积分包微信支付结果查询
export const checkPointsWxPayment = (params: ApiPayload): Promise<PaymentStatusResponse> => {
return request.post("/users/pointPackage/checkGatewayWxPayment", params);
return request.post("/users/pointPackage/checkGatewayWxPayment", params, paymentRequestConfig);
};
// 积分包列表

BIN
src/assets/images/explore/hero/heroBG1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 192 KiB

BIN
src/assets/images/explore/hero/heroBG3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 180 KiB

4
src/components/AssetGalleryTile/index.scss

@ -112,7 +112,6 @@
.assetGalleryTile_voice {
border-radius: 26px;
background: #b884dd;
.assetGalleryTile__voiceCover {
position: absolute;
@ -204,6 +203,7 @@
}
.assetGalleryTile_voice_disabled {
.assetGalleryTile__voiceCenterControl,
.assetGalleryTile__voiceCaption {
cursor: default;
@ -216,4 +216,4 @@
height: 100%;
object-fit: contain;
object-position: center;
}
}

34
src/dialog/index.tsx

@ -22,6 +22,7 @@ let dialogHistorySyncing = false;
let closeCharacterDetailModalImpl: (() => void) | null = null;
let closeAssetDetailsModalImpl: (() => void) | null = null;
let closeFoldersAssetDetailsModalImpl: (() => void) | null = null;
let paySubscribeDialogOpen = false;
export function closeCharacterDetailModal(): void {
closeCharacterDetailModalImpl?.();
@ -186,17 +187,28 @@ interface OpenPaySubscribeDialogOptions {
}
export async function openPaySubscribeDialog({ type, plan }: OpenPaySubscribeDialogOptions): Promise<void> {
const { default: PaySubscribe } = await import("@/dialog/pay-subscribe");
let close = () => {};
const modal = openDialogWithHistory({
title: null,
icon: null,
closable: true,
footer: null,
style: { width: "fit-content", maxWidth: "calc(100vw - 32px)", borderRadius: "9px", padding: "0" },
content: <PaySubscribe onClose={close} type={type} plan={plan} />,
});
close = modal.close;
if (paySubscribeDialogOpen) return;
paySubscribeDialogOpen = true;
try {
const { default: PaySubscribe } = await import("@/dialog/pay-subscribe");
let close = () => {};
const modal = openDialogWithHistory({
title: null,
icon: null,
closable: true,
footer: null,
style: { width: "fit-content", maxWidth: "calc(100vw - 32px)", borderRadius: "9px", padding: "0" },
content: <PaySubscribe onClose={close} type={type} plan={plan} />,
afterClose: () => {
paySubscribeDialogOpen = false;
},
});
close = modal.close;
} catch (error) {
paySubscribeDialogOpen = false;
throw error;
}
}
// 积分不足弹窗

418
src/dialog/pay-subscribe/index.tsx

@ -1,9 +1,9 @@
/**
* /
*
*
*
*/
* /
*
*
*
*/
import { Message, Modal } from "@arco-design/web-react";
import { useTranslation } from "react-i18next";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -111,16 +111,20 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
afterPaySuccessRef.current = afterPaySuccess;
}, [afterPaySuccess]);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("WXPAY");
/** 微信侧是否已点过「同意并支付」(本会话内切回微信不再出蒙层) */
const [wxAgreed, setWxAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [codeUrl, setCodeUrl] = useState("");
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("WXPAY");
/** 微信侧是否已点过「同意并支付」(本会话内切回微信不再出蒙层) */
const [wxAgreed, setWxAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [codeUrl, setCodeUrl] = useState("");
const wxOrderRef = useRef<PaymentOrderData | null>(null);
const pollAbortRef = useRef(false);
const pollStartTimeRef = useRef(0);
const pollTimerRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const requestSeqRef = useRef(0);
const activePollSeqRef = useRef(0);
const submittingRef = useRef(false);
const pollAbortRef = useRef(false);
const pollStartTimeRef = useRef(0);
const pollTimerRef = useRef<number | null>(null);
const paymentInfo = useMemo<PaymentInfo>(() => {
if (payType === "subscribe") {
@ -153,23 +157,9 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
/** 支付宝始终蒙层;微信仅首次同意前蒙层 */
const showAgreementOverlay = paymentMethod === "ALIPAY" || !wxAgreed;
useEffect(() => {
return () => {
pollAbortRef.current = true;
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
}, []);
const safeClose = useCallback(() => {
onClose();
}, [onClose]);
const requireLogin = useCallback((): boolean => {
if (!getToken() || !user) {
Message.warning(t("auth.please_login_first"));
const requireLogin = useCallback((): boolean => {
if (!getToken() || !user) {
Message.warning(t("auth.please_login_first"));
return false;
}
return true;
@ -177,12 +167,39 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
const stopPolling = useCallback(() => {
pollAbortRef.current = true;
activePollSeqRef.current += 1;
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const isPollingAborted = useCallback(() => pollAbortRef.current, []);
const isPollingActive = useCallback((pollSeq: number) => mountedRef.current && !pollAbortRef.current && activePollSeqRef.current === pollSeq, []);
const setSubmittingState = useCallback((nextSubmitting: boolean) => {
submittingRef.current = nextSubmitting;
if (mountedRef.current) {
setSubmitting(nextSubmitting);
}
}, []);
useEffect(() => {
return () => {
mountedRef.current = false;
requestSeqRef.current += 1;
stopPolling();
submittingRef.current = false;
};
}, [stopPolling]);
const safeClose = useCallback(() => {
onClose();
}, [onClose]);
const nextPaymentRequestSeq = useCallback(() => {
requestSeqRef.current += 1;
return requestSeqRef.current;
}, []);
const isPaymentRequestActive = useCallback((requestSeq: number) => mountedRef.current && requestSeqRef.current === requestSeq, []);
// 轮询支付状态
const startPaymentPoll = useCallback(
@ -190,18 +207,20 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
const tradeNo = orderRes.trade_no ?? orderRes.tradeNo;
if (!tradeNo) return;
stopPolling();
pollAbortRef.current = false;
pollStartTimeRef.current = Date.now();
void (async () => {
const { paymentMethod: method, payType: ctxPayType } = ctx;
for (;;) {
if (pollAbortRef.current) return;
if (Date.now() - pollStartTimeRef.current >= PAYMENT_POLL_MAX_MS) {
openPayNoticeModal("warning", {
stopPolling();
pollAbortRef.current = false;
pollStartTimeRef.current = Date.now();
const pollSeq = activePollSeqRef.current + 1;
activePollSeqRef.current = pollSeq;
void (async () => {
const { paymentMethod: method, payType: ctxPayType } = ctx;
for (;;) {
if (!isPollingActive(pollSeq)) return;
if (Date.now() - pollStartTimeRef.current >= PAYMENT_POLL_MAX_MS) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_timeout_title"),
content: t("subscribe.pay_result_timeout"),
okText: t("button.okay"),
@ -212,11 +231,11 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
try {
const { data: statusRes } = await fetchPaymentStatus(tradeNo, method, ctxPayType);
if (isPollingAborted()) return;
const tradeStatus = extractTradeStatus(statusRes, method);
if (!tradeStatus) {
await sleep(PAYMENT_POLL_INTERVAL_MS);
if (!isPollingActive(pollSeq)) return;
const tradeStatus = extractTradeStatus(statusRes, method);
if (!tradeStatus) {
await sleep(PAYMENT_POLL_INTERVAL_MS);
continue;
}
@ -248,30 +267,31 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
});
return;
}
await sleep(PAYMENT_POLL_INTERVAL_MS);
} catch {
await sleep(PAYMENT_POLL_INTERVAL_MS);
}
}
})();
},
[isPollingAborted, safeClose, setUser, stopPolling, t],
);
await sleep(PAYMENT_POLL_INTERVAL_MS);
} catch {
if (!isPollingActive(pollSeq)) return;
await sleep(PAYMENT_POLL_INTERVAL_MS);
}
}
})();
},
[isPollingActive, safeClose, setUser, stopPolling, t],
);
// 调度轮询
const schedulePoll = useCallback(
(orderRes: PaymentOrderData, method: PaymentMethod) => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current);
}
const ctx: PollContext = { paymentMethod: method, payType };
pollTimerRef.current = window.setTimeout(() => {
startPaymentPoll(orderRes, ctx);
}, PAYMENT_POLL_INTERVAL_MS);
},
[payType, startPaymentPoll],
);
if (!mountedRef.current) return;
stopPolling();
const ctx: PollContext = { paymentMethod: method, payType };
pollTimerRef.current = window.setTimeout(() => {
if (!mountedRef.current) return;
startPaymentPoll(orderRes, ctx);
}, PAYMENT_POLL_INTERVAL_MS);
},
[payType, startPaymentPoll, stopPolling],
);
// 解析套餐ID
const resolvePlanIds = useCallback(() => {
@ -296,31 +316,29 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
return requireLogin();
}, [requireLogin, resolvePlanIds, safeClose, t]);
/** 微信预下单:仅拉 code_url,不跳转 */
const fetchWxOrder = useCallback(
async (options?: { silent?: boolean }) => {
if (submitting) return null;
const { id, subscriptionId, packageId } = resolvePlanIds();
if (!id) {
if (!options?.silent) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_missing_plan_title"),
content: t("subscribe.missing_plan_info"),
okText: t("button.okay"),
onOk: safeClose,
});
}
return null;
}
if (options?.silent) {
if (!getToken() || !user) return null;
} else if (!requireLogin()) {
return null;
}
setSubmitting(true);
const returnUrl = buildPaymentReturnUrl(payType, "WXPAY");
/** 微信下单:仅拉 code_url,不跳转 */
const fetchWxOrder = useCallback(
async () => {
if (submittingRef.current) return null;
const requestSeq = nextPaymentRequestSeq();
stopPolling();
const { id, subscriptionId, packageId } = resolvePlanIds();
if (!id) {
openPayNoticeModal("warning", {
title: t("subscribe.pay_notice_missing_plan_title"),
content: t("subscribe.missing_plan_info"),
okText: t("button.okay"),
onOk: safeClose,
});
return null;
}
if (!requireLogin()) {
return null;
}
setSubmittingState(true);
const returnUrl = buildPaymentReturnUrl(payType, "WXPAY");
try {
if (payType === "subscribe") {
@ -328,60 +346,93 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
returnUrl,
subscriptionId,
});
if (!isPaymentRequestActive(requestSeq)) return null;
if (!res) return null;
if (!res.code_url) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
}
wxOrderRef.current = res;
setCodeUrl(res.code_url ?? "");
setCodeUrl(res.code_url);
return res;
}
const { data: res } = await createPointsOrderByWxPay({ returnUrl, packageId });
if (!isPaymentRequestActive(requestSeq)) return null;
if (!res) return null;
if (!res.code_url) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
}
wxOrderRef.current = res;
setCodeUrl(res.code_url ?? "");
setCodeUrl(res.code_url);
return res;
} catch {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
onOk: safeClose,
});
return null;
} finally {
setSubmitting(false);
}
},
[payType, requireLogin, resolvePlanIds, safeClose, submitting, t, user],
);
/** 支付宝下单:同意后才调用,拿链接并跳转 */
const fetchAlipayAndRedirect = useCallback(async () => {
if (submitting) return null;
if (!assertPlanAndLogin()) return null;
const { subscriptionId, packageId } = resolvePlanIds();
setSubmitting(true);
const returnUrl = buildPaymentReturnUrl(payType, "ALIPAY");
try {
const res = payType === "subscribe" ? (await createSubscriptionOrder({ returnUrl, subscriptionId })).data : (await createPointsOrder({ returnUrl, packageId })).data;
if (res?.url) {
schedulePoll(res, "ALIPAY");
location.href = res.url;
}
return res;
} catch {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
onOk: safeClose,
});
return null;
} finally {
setSubmitting(false);
}
}, [assertPlanAndLogin, payType, resolvePlanIds, safeClose, schedulePoll, submitting, t]);
} catch {
if (isPaymentRequestActive(requestSeq)) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
}
return null;
} finally {
if (isPaymentRequestActive(requestSeq)) {
setSubmittingState(false);
}
}
},
[isPaymentRequestActive, nextPaymentRequestSeq, payType, requireLogin, resolvePlanIds, safeClose, setSubmittingState, stopPolling, t],
);
/** 支付宝下单:同意后才调用,拿链接并跳转 */
const fetchAlipayAndRedirect = useCallback(async () => {
if (submittingRef.current) return null;
if (!assertPlanAndLogin()) return null;
const requestSeq = nextPaymentRequestSeq();
stopPolling();
const { subscriptionId, packageId } = resolvePlanIds();
setSubmittingState(true);
const returnUrl = buildPaymentReturnUrl(payType, "ALIPAY");
try {
const res = payType === "subscribe" ? (await createSubscriptionOrder({ returnUrl, subscriptionId })).data : (await createPointsOrder({ returnUrl, packageId })).data;
if (!isPaymentRequestActive(requestSeq)) return null;
if (res?.url) {
schedulePoll(res, "ALIPAY");
location.href = res.url;
return res;
}
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
return null;
} catch {
if (isPaymentRequestActive(requestSeq)) {
openPayNoticeModal("error", {
title: t("subscribe.pay_notice_start_failed_title"),
content: t("subscribe.pay_start_failed"),
okText: t("button.okay"),
});
}
return null;
} finally {
if (isPaymentRequestActive(requestSeq)) {
setSubmittingState(false);
}
}
}, [assertPlanAndLogin, isPaymentRequestActive, nextPaymentRequestSeq, payType, resolvePlanIds, schedulePoll, setSubmittingState, stopPolling, t]);
const resumeWxPolling = useCallback(() => {
if (wxOrderRef.current) {
@ -389,65 +440,46 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
}
}, [schedulePoll]);
/** 换套餐:重置微信同意态,预拉微信码 */
useEffect(() => {
if (!planId) return;
const cancellation = { current: false };
/** 换套餐:重置支付态,不自动创建订单 */
useEffect(() => {
nextPaymentRequestSeq();
stopPolling();
setSubmittingState(false);
setWxAgreed(false);
setPaymentMethod("WXPAY");
setCodeUrl("");
wxOrderRef.current = null;
setCodeUrl("");
}, [nextPaymentRequestSeq, planId, setSubmittingState, stopPolling]);
void (async () => {
if (!cancellation.current) {
await fetchWxOrder({ silent: true });
}
})();
const handlePaymentMethodChange = useCallback(
(method: PaymentMethod) => {
if (method === paymentMethod) return;
if (submittingRef.current) return;
return () => {
cancellation.current = true;
};
}, [fetchWxOrder, planId, stopPolling]);
const handlePaymentMethodChange = useCallback(
(method: PaymentMethod) => {
if (method === paymentMethod) return;
stopPolling();
setPaymentMethod(method);
if (method === "WXPAY") {
if (!codeUrl && !wxOrderRef.current?.code_url) {
void fetchWxOrder();
} else if (wxAgreed) {
resumeWxPolling();
}
}
},
[codeUrl, fetchWxOrder, paymentMethod, resumeWxPolling, stopPolling, wxAgreed],
);
const handleAgreeAndPay = useCallback(async () => {
if (submitting) return;
if (!assertPlanAndLogin()) return;
if (paymentMethod === "ALIPAY") {
await fetchAlipayAndRedirect();
return;
}
setWxAgreed(true);
let order: PaymentOrderData | null = wxOrderRef.current;
if (!order?.code_url && !codeUrl) {
order = await fetchWxOrder();
nextPaymentRequestSeq();
stopPolling();
setPaymentMethod(method);
setWxAgreed(false);
setCodeUrl("");
wxOrderRef.current = null;
},
[nextPaymentRequestSeq, paymentMethod, stopPolling],
);
const handleAgreeAndPay = useCallback(async () => {
if (submittingRef.current) return;
if (paymentMethod === "ALIPAY") {
await fetchAlipayAndRedirect();
return;
}
if (order) {
resumeWxPolling();
}
}, [assertPlanAndLogin, codeUrl, fetchAlipayAndRedirect, fetchWxOrder, paymentMethod, resumeWxPolling, submitting]);
const order = await fetchWxOrder();
if (order?.code_url) {
setWxAgreed(true);
resumeWxPolling();
}
}, [fetchAlipayAndRedirect, fetchWxOrder, paymentMethod, resumeWxPolling]);
const wxLogo = new URL("@/assets/images/logo_wxpay.png", import.meta.url).href;
const alipayLogo = new URL("@/assets/images/logo_alipay.png", import.meta.url).href;
@ -461,6 +493,7 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
<button
type="button"
className={["pay_subscribe_modal__method", paymentMethod === "WXPAY" ? "is_active" : ""].filter(Boolean).join(" ")}
disabled={submitting}
onClick={() => {
handlePaymentMethodChange("WXPAY");
}}
@ -471,6 +504,7 @@ export default function PaySubscribe({ onClose, type, plan, afterPaySuccess }: P
<button
type="button"
className={["pay_subscribe_modal__method", paymentMethod === "ALIPAY" ? "is_active" : ""].filter(Boolean).join(" ")}
disabled={submitting}
onClick={() => {
handlePaymentMethodChange("ALIPAY");
}}

3
src/locales/en-US.json

@ -80,7 +80,7 @@
"layout.nav.explore": "Explore",
"layout.nav.characters": "Characters",
"layout.nav.create": "Create",
"layout.nav.canvas": "Popi.TV",
"layout.nav.canvas": "POPi.TV",
"layout.nav.canvasBadge": "BETA",
"layout.nav.voice": "Voice",
"layout.nav.points": "Points",
@ -937,7 +937,6 @@
"pages.voice.speech.emotion.surprised": "Surprised",
"pages.voice.speech.emotion.calm": "Calm",
"pages.voice.speech.emotion.fluent": "Fluent",
"pages.voice.speech.emotion.whisper": "Whisper",
"pages.voice.speech.shortcut.pause": "<#> Pause",
"pages.voice.speech.shortcut.pauseTooltipLine1": "Insert pauses in your text to control pacing precisely.",
"pages.voice.speech.shortcut.pauseTooltipLine2": "Choose a preset duration or enter seconds directly.",

5
src/locales/zh-CN.json

@ -80,7 +80,7 @@
"layout.nav.explore": "探索",
"layout.nav.characters": "角色",
"layout.nav.create": "创造",
"layout.nav.canvas": "Popi.TV",
"layout.nav.canvas": "画布",
"layout.nav.canvasBadge": "BETA",
"layout.nav.voice": "声音",
"layout.nav.points": "积分",
@ -929,7 +929,7 @@
"pages.voice.speech.emotion.aria": "选择情绪",
"pages.voice.speech.emotion.remove": "移除情绪",
"pages.voice.speech.emotion.reset": "重置",
"pages.voice.speech.emotion.happy": "高兴",
"pages.voice.speech.emotion.happy": "开心",
"pages.voice.speech.emotion.sad": "难过",
"pages.voice.speech.emotion.angry": "生气",
"pages.voice.speech.emotion.fearful": "害怕",
@ -937,7 +937,6 @@
"pages.voice.speech.emotion.surprised": "惊讶",
"pages.voice.speech.emotion.calm": "中性",
"pages.voice.speech.emotion.fluent": "生动",
"pages.voice.speech.emotion.whisper": "低语",
"pages.voice.speech.shortcut.pause": "<#>停顿",
"pages.voice.speech.shortcut.pauseTooltipLine1": "在文中插入停顿,精准掌控音频节奏",
"pages.voice.speech.shortcut.pauseTooltipLine2": "支持选择预设时长,或直接输入秒数",

5
src/locales/zh-TW.json

@ -80,7 +80,7 @@
"layout.nav.explore": "探索",
"layout.nav.characters": "角色",
"layout.nav.create": "創造",
"layout.nav.canvas": "Popi.TV",
"layout.nav.canvas": "畫布",
"layout.nav.canvasBadge": "BETA",
"layout.nav.voice": "聲音",
"layout.nav.points": "積分",
@ -907,7 +907,7 @@
"pages.voice.speech.emotion.aria": "選擇情緒",
"pages.voice.speech.emotion.remove": "移除情緒",
"pages.voice.speech.emotion.reset": "重設",
"pages.voice.speech.emotion.happy": "高興",
"pages.voice.speech.emotion.happy": "開心",
"pages.voice.speech.emotion.sad": "難過",
"pages.voice.speech.emotion.angry": "生氣",
"pages.voice.speech.emotion.fearful": "害怕",
@ -915,7 +915,6 @@
"pages.voice.speech.emotion.surprised": "驚訝",
"pages.voice.speech.emotion.calm": "中性",
"pages.voice.speech.emotion.fluent": "生動",
"pages.voice.speech.emotion.whisper": "低語",
"pages.voice.speech.shortcut.pause": "<#>停頓",
"pages.voice.speech.shortcut.pauseTooltipLine1": "在文中插入停頓,精準掌控音頻節奏",
"pages.voice.speech.shortcut.pauseTooltipLine2": "支持選擇預設時長,或直接輸入秒數",

2
src/pages/Explore/ExploreTemplateFeed/ExploreTemplateFeed.scss

@ -177,4 +177,4 @@
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
}

199
src/pages/Voice/components/speech/VoiceSpeechPanel.tsx

@ -111,7 +111,6 @@ const SPEECH_EMOTION_OPTIONS = [
"surprised",
"calm",
"fluent",
"whisper",
] as const;
type SpeechEmotionKey = (typeof SPEECH_EMOTION_OPTIONS)[number];
@ -543,16 +542,20 @@ function isInlineAtomNode(node: ProseMirrorNode): boolean {
return SPEECH_INLINE_ATOM_NODE_NAMES.includes(node.type.name as (typeof SPEECH_INLINE_ATOM_NODE_NAMES)[number]);
}
function paragraphEndsWithInlineAtom(paragraph: ProseMirrorNode): boolean {
function isTrailingCursorAnchorNode(node: ProseMirrorNode): boolean {
return isInlineAtomNode(node) || node.type.name === SPEECH_EMOTION_SEGMENT_NODE_NAME;
}
function paragraphEndsWithTrailingCursorAnchor(paragraph: ProseMirrorNode): boolean {
const last = paragraph.lastChild;
return last != null && isInlineAtomNode(last);
return last != null && isTrailingCursorAnchorNode(last);
}
function paragraphHasValidTrailingSpacer(paragraph: ProseMirrorNode): boolean {
const last = paragraph.lastChild;
if (!last?.isText || last.text !== SPEECH_CURSOR_ZWSP || paragraph.childCount < 2) return false;
const prev = paragraph.child(paragraph.childCount - 2);
return isInlineAtomNode(prev);
return isTrailingCursorAnchorNode(prev);
}
function paragraphHasOrphanSpacer(paragraph: ProseMirrorNode): boolean {
@ -560,7 +563,7 @@ function paragraphHasOrphanSpacer(paragraph: ProseMirrorNode): boolean {
if (!last?.isText || last.text !== SPEECH_CURSOR_ZWSP) return false;
if (paragraph.childCount < 2) return true;
const prev = paragraph.child(paragraph.childCount - 2);
return !isInlineAtomNode(prev);
return !isTrailingCursorAnchorNode(prev);
}
function syncTrailingSpacers(tr: Transaction): boolean {
@ -578,7 +581,7 @@ function syncTrailingSpacers(tr: Transaction): boolean {
return;
}
if (paragraphEndsWithInlineAtom(node) && !paragraphHasValidTrailingSpacer(node)) {
if (paragraphEndsWithTrailingCursorAnchor(node) && !paragraphHasValidTrailingSpacer(node)) {
updates.push({ from: paragraphEnd, insert: SPEECH_CURSOR_ZWSP });
}
});
@ -598,29 +601,33 @@ function syncTrailingSpacers(tr: Transaction): boolean {
return modified;
}
function mapSelectionAcrossSpacerInsert(tr: Transaction, selectionFrom: number): void {
function mapSelectionAcrossSpacerInsert(tr: Transaction, selectionFrom: number): boolean {
const $from = tr.doc.resolve(selectionFrom);
if ($from.nodeBefore == null || !isInlineAtomNode($from.nodeBefore)) return;
if ($from.nodeBefore == null || !isTrailingCursorAnchorNode($from.nodeBefore)) return false;
const nodeAfter = $from.nodeAfter;
if (nodeAfter?.isText && nodeAfter.text === SPEECH_CURSOR_ZWSP) {
tr.setSelection(TextSelection.create(tr.doc, selectionFrom));
return true;
}
return false;
}
function createSpeechPromptTrailingCursorPlugin() {
return new Plugin({
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null;
const hasDocChange = transactions.some((transaction) => transaction.docChanged);
if (!hasDocChange) return null;
const tr = newState.tr;
if (!syncTrailingSpacers(tr)) return null;
let modified = syncTrailingSpacers(tr);
if (newState.selection.empty) {
mapSelectionAcrossSpacerInsert(tr, newState.selection.from);
modified = mapSelectionAcrossSpacerInsert(tr, newState.selection.from) || modified;
}
return tr;
return modified ? tr : null;
},
});
}
@ -639,12 +646,26 @@ type SpeechEmotionSelectionState = {
canApply: boolean;
};
type SpeechEmotionPopoverState = {
type SpeechEmotionSelectionPopoverState = {
kind: "selection";
from: number;
to: number;
top: number;
left: number;
} | null;
};
type SpeechEmotionSegmentPopoverState = {
kind: "segment";
pos: number;
emotion: SpeechEmotionKey;
top: number;
left: number;
};
type SpeechEmotionPopoverState =
| SpeechEmotionSelectionPopoverState
| SpeechEmotionSegmentPopoverState
| null;
function createEmptySpeechEmotionSelectionState(): SpeechEmotionSelectionState {
return {
@ -698,6 +719,7 @@ function getSpeechSelectionPopoverState(editor: Editor): SpeechEmotionPopoverSta
if (empty || from >= to) return null;
const rect = editor.view.coordsAtPos(from);
return {
kind: "selection",
from,
to,
top: rect.bottom + 8,
@ -705,7 +727,7 @@ function getSpeechSelectionPopoverState(editor: Editor): SpeechEmotionPopoverSta
};
}
function applySpeechEmotionToSelection(editor: Editor, popover: NonNullable<SpeechEmotionPopoverState>, emotion: SpeechEmotionKey, maxLength: number): boolean {
function applySpeechEmotionToSelection(editor: Editor, popover: SpeechEmotionSelectionPopoverState, emotion: SpeechEmotionKey, maxLength: number): boolean {
const { state } = editor;
const from = Math.max(1, Math.min(popover.from, state.doc.content.size));
const to = Math.max(from, Math.min(popover.to, state.doc.content.size));
@ -730,6 +752,56 @@ function applySpeechEmotionToSelection(editor: Editor, popover: NonNullable<Spee
return true;
}
function getSpeechEmotionSegmentAtPos(editor: Editor, pos: number): ProseMirrorNode | null {
const node = editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== SPEECH_EMOTION_SEGMENT_NODE_NAME) return null;
return node;
}
function openSpeechEmotionSegmentPopover(editor: Editor, pos: number, anchor: HTMLElement): boolean {
const node = getSpeechEmotionSegmentAtPos(editor, pos);
if (!node) return false;
const rawEmotion = String(node.attrs.emotion ?? "");
const emotion = isSpeechEmotionKey(rawEmotion) ? rawEmotion : "calm";
const rect = anchor.getBoundingClientRect();
closeSpeechPausePopover();
closeSpeechModalPopover();
openSpeechEmotionPopover({
kind: "segment",
pos,
emotion,
top: rect.bottom + 8,
left: rect.left,
});
return true;
}
function applySpeechEmotionToSegment(editor: Editor, popover: SpeechEmotionSegmentPopoverState, emotion: SpeechEmotionKey, maxLength: number): boolean {
const { state } = editor;
const node = getSpeechEmotionSegmentAtPos(editor, popover.pos);
if (!node) return false;
const rawEmotion = String(node.attrs.emotion ?? "");
const currentEmotion = isSpeechEmotionKey(rawEmotion) ? rawEmotion : "calm";
const content = proseMirrorFragmentToSpeechText(node.content);
const currentLength = tiptapDocToSpeechText(editor.getJSON()).length;
const nextLength =
currentLength
- buildSpeechEmotionText(currentEmotion, content).length
+ buildSpeechEmotionText(emotion, content).length;
if (nextLength > maxLength) return false;
const tr = state.tr.setNodeMarkup(popover.pos, undefined, {
...node.attrs,
emotion,
});
editor.view.dispatch(tr.scrollIntoView());
editor.commands.focus();
return true;
}
// ---------------------------------------------------------------------------
// Popover stores
// ---------------------------------------------------------------------------
@ -783,7 +855,8 @@ function shouldIgnoreEmotionPopoverDismiss(target: EventTarget | null): boolean
if (!(target instanceof Element)) return false;
return Boolean(
target.closest(".voicePage__speechEmotionPopover")
|| target.closest(".voicePage__speechShortcut_emotion"),
|| target.closest(".voicePage__speechShortcut_emotion")
|| target.closest("[data-speech-emotion-label=\"true\"]"),
);
}
@ -993,13 +1066,24 @@ declare module "@tiptap/core" {
function SpeechEmotionSegmentView({ editor, getPos, node }: NodeViewProps) {
const { t } = useTranslation();
const [popoverState, setPopoverState] = useState<SpeechEmotionPopoverState>(getSpeechEmotionPopoverState());
const rawEmotion = String(node.attrs.emotion ?? "");
const emotion = isSpeechEmotionKey(rawEmotion) ? rawEmotion : "calm";
const pos = typeof getPos === "function" ? getPos() : null;
const isPopoverOpen = typeof pos === "number" && popoverState?.kind === "segment" && popoverState.pos === pos;
useEffect(() => subscribeSpeechEmotionPopover(setPopoverState), []);
const handleLabelMouseDown = (event: ReactMouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
if (typeof pos !== "number") return;
openSpeechEmotionSegmentPopover(editor, pos, event.currentTarget);
};
const handleRemove = (event: ReactMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const pos = typeof getPos === "function" ? getPos() : null;
if (typeof pos !== "number") return;
const currentNode = editor.state.doc.nodeAt(pos);
@ -1011,6 +1095,7 @@ function SpeechEmotionSegmentView({ editor, getPos, node }: NodeViewProps) {
const replacementSize = replacement.reduce((sum, item) => sum + item.nodeSize, 0);
tr.setSelection(TextSelection.create(tr.doc, Math.min(pos + replacementSize, tr.doc.content.size - 1)));
editor.view.dispatch(tr.scrollIntoView());
closeSpeechEmotionPopover();
editor.commands.focus();
};
@ -1021,19 +1106,30 @@ function SpeechEmotionSegmentView({ editor, getPos, node }: NodeViewProps) {
data-speech-emotion-segment="true"
data-emotion={emotion}
>
<span className="voicePage__speechEmotionLabel" contentEditable={false}>
<span
className={classNames(
"voicePage__speechEmotionLabel",
isPopoverOpen && "voicePage__speechEmotionLabel_active",
)}
contentEditable={false}
data-speech-emotion-label="true"
onMouseDown={handleLabelMouseDown}
>
<span>{t(`pages.voice.speech.emotion.${emotion}`)}</span>
<button
type="button"
className="voicePage__speechEmotionRemove"
aria-label={t("pages.voice.speech.emotion.remove")}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={handleRemove}
>
x
</button>
{isPopoverOpen ? (
<button
type="button"
className="voicePage__speechEmotionRemove"
aria-label={t("pages.voice.speech.emotion.remove")}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={handleRemove}
>
x
</button>
) : null}
</span>
<NodeViewContent<"span"> as="span" className="voicePage__speechEmotionContent" />
</NodeViewWrapper>
@ -1379,7 +1475,11 @@ function SpeechEmotionPopoverPortal({ editor, maxLength }: { editor: Editor | nu
const applyEmotion = useCallback(
(emotion: SpeechEmotionKey) => {
if (!editor || !popover) return;
applySpeechEmotionToSelection(editor, popover, emotion, maxLength);
if (popover.kind === "selection") {
applySpeechEmotionToSelection(editor, popover, emotion, maxLength);
} else {
applySpeechEmotionToSegment(editor, popover, emotion, maxLength);
}
closeSpeechEmotionPopover();
},
[editor, maxLength, popover],
@ -1395,22 +1495,29 @@ function SpeechEmotionPopoverPortal({ editor, maxLength }: { editor: Editor | nu
style={{ top: popover.top, left: popover.left }}
>
<div className="voicePage__speechEmotionPopoverList" role="listbox">
{SPEECH_EMOTION_OPTIONS.map((emotion) => (
<button
key={emotion}
type="button"
role="option"
className="voicePage__speechEmotionPopoverOption"
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
applyEmotion(emotion);
}}
>
{t(`pages.voice.speech.emotion.${emotion}`)}
</button>
))}
{SPEECH_EMOTION_OPTIONS.map((emotion) => {
const isActive = popover.kind === "segment" && popover.emotion === emotion;
return (
<button
key={emotion}
type="button"
role="option"
aria-selected={isActive}
className={classNames(
"voicePage__speechEmotionPopoverOption",
isActive && "voicePage__speechEmotionPopoverOption_active",
)}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
applyEmotion(emotion);
}}
>
{t(`pages.voice.speech.emotion.${emotion}`)}
</button>
);
})}
</div>
</div>,
document.body,

82
src/pages/Voice/index.scss

@ -2058,75 +2058,70 @@ $upload-crop-selection-rail-height: 3px;
--speech-emotion-label: #6b7280;
display: inline;
padding: 3px 5px;
border-radius: 6px;
background: var(--speech-emotion-bg);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.voicePage__speechEmotionSegment_happy {
--speech-emotion-bg: #fff4c2;
--speech-emotion-label: #d49b00;
--speech-emotion-bg: rgba(246, 195, 68, 0.16);
--speech-emotion-label: rgb(246, 195, 68);
}
.voicePage__speechEmotionSegment_sad {
--speech-emotion-bg: #dfeeff;
--speech-emotion-label: #3977d3;
--speech-emotion-bg: rgba(90, 127, 191, 0.16);
--speech-emotion-label: rgb(90, 127, 191);
}
.voicePage__speechEmotionSegment_angry {
--speech-emotion-bg: #ffe1df;
--speech-emotion-label: #d94a48;
--speech-emotion-bg: rgba(200, 76, 76, 0.16);
--speech-emotion-label: rgb(200, 76, 76);
}
.voicePage__speechEmotionSegment_fearful {
--speech-emotion-bg: #eadfff;
--speech-emotion-label: #7a54d8;
--speech-emotion-bg: rgba(122, 108, 157, 0.16);
--speech-emotion-label: rgb(122, 108, 157);
}
.voicePage__speechEmotionSegment_disgusted {
--speech-emotion-bg: #dff4df;
--speech-emotion-label: #3f9c54;
--speech-emotion-bg: rgba(110, 139, 94, 0.16);
--speech-emotion-label: rgb(110, 139, 94);
}
.voicePage__speechEmotionSegment_surprised {
--speech-emotion-bg: #ffe9cf;
--speech-emotion-label: #dd7d22;
--speech-emotion-bg: rgba(242, 153, 74, 0.16);
--speech-emotion-label: rgb(242, 153, 74);
}
.voicePage__speechEmotionSegment_calm {
--speech-emotion-bg: #e8edf4;
--speech-emotion-label: #66758a;
--speech-emotion-bg: rgba(154, 160, 166, 0.16);
--speech-emotion-label: rgb(154, 160, 166);
}
.voicePage__speechEmotionSegment_fluent {
--speech-emotion-bg: #dff7f5;
--speech-emotion-label: #229b92;
}
.voicePage__speechEmotionSegment_whisper {
--speech-emotion-bg: #efe7df;
--speech-emotion-label: #8a6a52;
--speech-emotion-bg: rgba(79, 179, 191, 0.16);
--speech-emotion-label: rgb(79, 179, 191);
}
.voicePage__speechEmotionLabel {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 22px;
min-height: 24px;
margin-right: 5px;
padding: 2px 5px;
padding: 2px 7px;
border-radius: 6px;
background: var(--speech-emotion-label);
font-size: 12px;
font-size: 13px;
font-weight: 600;
line-height: 18px;
line-height: 20px;
color: #ffffff;
vertical-align: baseline;
cursor: pointer;
user-select: none;
}
.voicePage__speechEmotionLabel_active {
gap: 5px;
}
.voicePage__speechEmotionRemove {
display: inline-flex;
align-items: center;
@ -2136,10 +2131,10 @@ $upload-crop-selection-rail-height: 3px;
margin: 0 -2px 0 0;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.24);
border-radius: 0;
background: transparent;
color: #ffffff;
font-size: 10px;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
@ -2147,6 +2142,10 @@ $upload-crop-selection-rail-height: 3px;
.voicePage__speechEmotionContent {
display: inline;
padding: 3px 5px;
background: var(--speech-emotion-bg);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.voicePage__lyricsSectionTagPopover {
@ -2242,7 +2241,7 @@ $upload-crop-selection-rail-height: 3px;
.voicePage__speechEmotionPopover {
position: fixed;
z-index: 10050;
width: 188px;
width: 155px;
padding: 5px;
border-radius: 9px;
background: #ffffff;
@ -2261,22 +2260,25 @@ $upload-crop-selection-rail-height: 3px;
align-items: center;
justify-content: center;
min-width: 0;
width: 70px;
height: 24px;
margin: 0;
padding: 0 8px;
border: 1px solid #e5e6eb;
padding: 0;
border: none;
border-radius: 5px;
background: #f9f9f9;
font-size: 12px;
font-weight: 500;
font-size: 14px;
font-weight: 400;
line-height: normal;
color: #666666;
cursor: pointer;
}
.voicePage__speechEmotionPopoverOption:hover {
.voicePage__speechEmotionPopoverOption:hover,
.voicePage__speechEmotionPopoverOption_active,
.voicePage__speechEmotionPopoverOption_active:hover {
background: #f0f0f0;
color: #5c3bc7;
color: #666666;
}
.voicePage__speechPausePopover {

Loading…
Cancel
Save